multi registry support v3
This commit is contained in:
@@ -45,6 +45,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartbucket": "^3.3.10"
|
"@push.rocks/smartbucket": "^3.3.10",
|
||||||
|
"@push.rocks/smartpath": "^6.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@push.rocks/smartbucket':
|
'@push.rocks/smartbucket':
|
||||||
specifier: ^3.3.10
|
specifier: ^3.3.10
|
||||||
version: 3.3.10
|
version: 3.3.10
|
||||||
|
'@push.rocks/smartpath':
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@git.zone/tsbuild':
|
'@git.zone/tsbuild':
|
||||||
specifier: ^3.1.0
|
specifier: ^3.1.0
|
||||||
|
|||||||
516
readme.md
516
readme.md
@@ -1,17 +1,39 @@
|
|||||||
# @push.rocks/smartregistry
|
# @push.rocks/smartregistry
|
||||||
|
|
||||||
A TypeScript library implementing the OCI Distribution Specification v1.1 for building container and artifact registries.
|
A composable TypeScript library implementing both OCI Distribution Specification v1.1 and NPM Registry API for building unified container and package registries.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **OCI Distribution Spec v1.1 Compliant**: Implements all required and optional endpoints
|
### Dual Protocol Support
|
||||||
- **Cloud-Agnostic Storage**: Uses @push.rocks/smartbucket for S3-compatible object storage
|
- **OCI Distribution Spec v1.1**: Full container registry with manifest/blob operations
|
||||||
- **Pluggable Authentication**: Async callbacks for login and authorization
|
- **NPM Registry API**: Complete package registry with publish/install/search
|
||||||
- **Bearer Token Auth**: JWT-based authentication following Docker Registry Token Authentication spec
|
|
||||||
- **Programmatic API**: Use as a library in any Node.js/TypeScript application
|
### Unified Architecture
|
||||||
- **Full CRUD Operations**: Push, pull, list, and delete manifests and blobs
|
- **Composable Design**: Core infrastructure with protocol plugins
|
||||||
- **Content Discovery**: Tag listing and referrers API for artifact relationships
|
- **Shared Storage**: Cloud-agnostic S3-compatible backend (@push.rocks/smartbucket)
|
||||||
- **Chunked Uploads**: Support for large blob uploads with resumable sessions
|
- **Unified Authentication**: Scope-based permissions across both protocols
|
||||||
|
- **Path-based Routing**: `/oci/*` for containers, `/npm/*` for packages
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
- NPM UUID tokens for package operations
|
||||||
|
- OCI JWT tokens for container operations
|
||||||
|
- Unified scope system: `npm:package:foo:write`, `oci:repository:bar:push`
|
||||||
|
- Pluggable via async callbacks
|
||||||
|
|
||||||
|
### Comprehensive Feature Set
|
||||||
|
|
||||||
|
**OCI Features:**
|
||||||
|
- ✅ Pull operations (manifests, blobs)
|
||||||
|
- ✅ Push operations (chunked uploads)
|
||||||
|
- ✅ Content discovery (tags, referrers API)
|
||||||
|
- ✅ Content management (deletion)
|
||||||
|
|
||||||
|
**NPM Features:**
|
||||||
|
- ✅ Package publish/unpublish
|
||||||
|
- ✅ Package download (tarballs)
|
||||||
|
- ✅ Metadata & search
|
||||||
|
- ✅ Dist-tag management
|
||||||
|
- ✅ Token management
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -21,31 +43,14 @@ npm install @push.rocks/smartregistry
|
|||||||
pnpm add @push.rocks/smartregistry
|
pnpm add @push.rocks/smartregistry
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Quick Start
|
||||||
|
|
||||||
### Basic Setup
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartRegistry, IRegistryConfig, TLoginCallback, TAuthCallback } from '@push.rocks/smartregistry';
|
import { SmartRegistry, IRegistryConfig } from '@push.rocks/smartregistry';
|
||||||
|
|
||||||
// Implement login callback
|
|
||||||
const loginCallback: TLoginCallback = async (credentials) => {
|
|
||||||
// Validate credentials and return JWT token
|
|
||||||
// This should create a proper JWT with required claims
|
|
||||||
return generateJWT(credentials.username);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Implement authorization callback
|
|
||||||
const authCallback: TAuthCallback = async (token, repository, action) => {
|
|
||||||
// Validate token and check permissions
|
|
||||||
const claims = verifyJWT(token);
|
|
||||||
return hasPermission(claims, repository, action);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Configure registry
|
|
||||||
const config: IRegistryConfig = {
|
const config: IRegistryConfig = {
|
||||||
storage: {
|
storage: {
|
||||||
accessKey: 'your-s3-access-key',
|
accessKey: 'your-s3-key',
|
||||||
accessSecret: 'your-s3-secret',
|
accessSecret: 'your-s3-secret',
|
||||||
endpoint: 's3.amazonaws.com',
|
endpoint: 's3.amazonaws.com',
|
||||||
port: 443,
|
port: 443,
|
||||||
@@ -53,117 +58,406 @@ const config: IRegistryConfig = {
|
|||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
bucketName: 'my-registry',
|
bucketName: 'my-registry',
|
||||||
},
|
},
|
||||||
serviceName: 'my-registry',
|
auth: {
|
||||||
tokenRealm: 'https://auth.example.com/token',
|
jwtSecret: 'your-secret-key',
|
||||||
loginCallback,
|
tokenStore: 'memory',
|
||||||
authCallback,
|
npmTokens: { enabled: true },
|
||||||
|
ociTokens: {
|
||||||
|
enabled: true,
|
||||||
|
realm: 'https://auth.example.com/token',
|
||||||
|
service: 'my-registry',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
oci: {
|
||||||
|
enabled: true,
|
||||||
|
basePath: '/oci',
|
||||||
|
},
|
||||||
|
npm: {
|
||||||
|
enabled: true,
|
||||||
|
basePath: '/npm',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create and initialize registry
|
|
||||||
const registry = new SmartRegistry(config);
|
const registry = new SmartRegistry(config);
|
||||||
await registry.init();
|
await registry.init();
|
||||||
|
|
||||||
|
// Handle requests
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/npm/express',
|
||||||
|
headers: {},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Integration with HTTP Server
|
## Architecture
|
||||||
|
|
||||||
```typescript
|
### Directory Structure
|
||||||
import express from 'express';
|
|
||||||
|
|
||||||
const app = express();
|
```
|
||||||
|
ts/
|
||||||
// OCI Distribution API endpoints
|
├── core/ # Shared infrastructure
|
||||||
app.get('/v2/', (req, res) => {
|
│ ├── classes.baseregistry.ts
|
||||||
res.status(200).json({});
|
│ ├── classes.registrystorage.ts
|
||||||
});
|
│ ├── classes.authmanager.ts
|
||||||
|
│ └── interfaces.core.ts
|
||||||
app.get('/v2/:name(*)/manifests/:reference', async (req, res) => {
|
├── oci/ # OCI implementation
|
||||||
const { name, reference } = req.params;
|
│ ├── classes.ociregistry.ts
|
||||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
│ └── interfaces.oci.ts
|
||||||
|
├── npm/ # NPM implementation
|
||||||
const result = await registry.getManifest(name, reference, token);
|
│ ├── classes.npmregistry.ts
|
||||||
|
│ └── interfaces.npm.ts
|
||||||
if ('errors' in result) {
|
└── classes.smartregistry.ts # Main orchestrator
|
||||||
return res.status(404).json(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', result.contentType);
|
|
||||||
res.setHeader('Docker-Content-Digest', result.digest);
|
|
||||||
res.send(result.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/v2/:name(*)/blobs/:digest', async (req, res) => {
|
|
||||||
const { name, digest } = req.params;
|
|
||||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
|
||||||
|
|
||||||
const result = await registry.getBlob(name, digest, token);
|
|
||||||
|
|
||||||
if ('errors' in result) {
|
|
||||||
return res.status(404).json(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/octet-stream');
|
|
||||||
res.send(result.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ... implement other endpoints
|
|
||||||
|
|
||||||
app.listen(5000);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Authentication Flow
|
### Request Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP Request
|
||||||
|
↓
|
||||||
|
SmartRegistry (orchestrator)
|
||||||
|
↓
|
||||||
|
Path-based routing
|
||||||
|
├─→ /oci/* → OciRegistry
|
||||||
|
└─→ /npm/* → NpmRegistry
|
||||||
|
↓
|
||||||
|
Shared Storage & Auth
|
||||||
|
↓
|
||||||
|
S3-compatible backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### OCI Registry (Container Images)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Client requests without token
|
// Pull an image
|
||||||
const challenge = registry.getAuthChallenge('library/nginx', ['pull', 'push']);
|
const response = await registry.handleRequest({
|
||||||
// Returns: Bearer realm="https://auth.example.com/token",service="my-registry",scope="repository:library/nginx:pull,push"
|
method: 'GET',
|
||||||
|
path: '/oci/v2/library/nginx/manifests/latest',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer <token>',
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
// Client authenticates
|
// Push a blob
|
||||||
const token = await registry.login({ username: 'user', password: 'pass' });
|
const uploadInit = await registry.handleRequest({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/oci/v2/myapp/blobs/uploads/',
|
||||||
|
headers: { 'Authorization': 'Bearer <token>' },
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
// Client uses token for subsequent requests
|
const uploadId = uploadInit.headers['Docker-Upload-UUID'];
|
||||||
const manifest = await registry.getManifest('library/nginx', 'latest', token);
|
|
||||||
|
await registry.handleRequest({
|
||||||
|
method: 'PUT',
|
||||||
|
path: `/oci/v2/myapp/blobs/uploads/${uploadId}`,
|
||||||
|
headers: { 'Authorization': 'Bearer <token>' },
|
||||||
|
query: { digest: 'sha256:abc123...' },
|
||||||
|
body: blobData,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### NPM Registry (Packages)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Install a package (get metadata)
|
||||||
|
const metadata = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/npm/express',
|
||||||
|
headers: {},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download tarball
|
||||||
|
const tarball = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/npm/express/-/express-4.18.0.tgz',
|
||||||
|
headers: {},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish a package
|
||||||
|
const publishResponse = await registry.handleRequest({
|
||||||
|
method: 'PUT',
|
||||||
|
path: '/npm/my-package',
|
||||||
|
headers: { 'Authorization': 'Bearer <npm-token>' },
|
||||||
|
query: {},
|
||||||
|
body: {
|
||||||
|
name: 'my-package',
|
||||||
|
versions: {
|
||||||
|
'1.0.0': { /* version metadata */ },
|
||||||
|
},
|
||||||
|
'dist-tags': { latest: '1.0.0' },
|
||||||
|
_attachments: {
|
||||||
|
'my-package-1.0.0.tgz': {
|
||||||
|
content_type: 'application/octet-stream',
|
||||||
|
data: '<base64-tarball>',
|
||||||
|
length: 12345,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search packages
|
||||||
|
const searchResults = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/npm/-/v1/search',
|
||||||
|
headers: {},
|
||||||
|
query: { text: 'express', size: '20' },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// NPM Login
|
||||||
|
const authManager = registry.getAuthManager();
|
||||||
|
|
||||||
|
// Authenticate user
|
||||||
|
const userId = await authManager.authenticate({
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create NPM token
|
||||||
|
const npmToken = await authManager.createNpmToken(userId, false);
|
||||||
|
|
||||||
|
// Create OCI token with scopes
|
||||||
|
const ociToken = await authManager.createOciToken(
|
||||||
|
userId,
|
||||||
|
['oci:repository:myapp:push', 'oci:repository:myapp:pull'],
|
||||||
|
3600
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate any token
|
||||||
|
const token = await authManager.validateToken(npmToken, 'npm');
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
const canWrite = await authManager.authorize(
|
||||||
|
token,
|
||||||
|
'npm:package:my-package',
|
||||||
|
'write'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Storage Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
storage: {
|
||||||
|
accessKey: string; // S3 access key
|
||||||
|
accessSecret: string; // S3 secret key
|
||||||
|
endpoint: string; // S3 endpoint
|
||||||
|
port?: number; // Default: 443
|
||||||
|
useSsl?: boolean; // Default: true
|
||||||
|
region?: string; // Default: 'us-east-1'
|
||||||
|
bucketName: string; // Bucket name
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
auth: {
|
||||||
|
jwtSecret: string; // Secret for signing JWTs
|
||||||
|
tokenStore: 'memory' | 'redis' | 'database';
|
||||||
|
npmTokens: {
|
||||||
|
enabled: boolean;
|
||||||
|
defaultReadonly?: boolean;
|
||||||
|
};
|
||||||
|
ociTokens: {
|
||||||
|
enabled: boolean;
|
||||||
|
realm: string; // Auth server URL
|
||||||
|
service: string; // Service name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protocol Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
oci?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath: string; // Default: '/oci'
|
||||||
|
features?: {
|
||||||
|
referrers?: boolean;
|
||||||
|
deletion?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
npm?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath: string; // Default: '/npm'
|
||||||
|
features?: {
|
||||||
|
publish?: boolean;
|
||||||
|
unpublish?: boolean;
|
||||||
|
search?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
### Pull Operations (Required)
|
### Core Classes
|
||||||
|
|
||||||
- `getManifest(repository, reference, token?)` - Download a manifest
|
#### SmartRegistry
|
||||||
- `headManifest(repository, reference, token?)` - Check manifest existence
|
|
||||||
- `getBlob(repository, digest, token?, range?)` - Download a blob
|
|
||||||
- `headBlob(repository, digest, token?)` - Check blob existence
|
|
||||||
|
|
||||||
### Push Operations
|
Main orchestrator class.
|
||||||
|
|
||||||
- `initiateUpload(repository, token, mountDigest?, fromRepository?)` - Start blob upload
|
**Methods:**
|
||||||
- `uploadChunk(uploadId, data, contentRange, token)` - Upload blob chunk
|
- `init()` - Initialize the registry
|
||||||
- `completeUpload(uploadId, digest, token, finalData?)` - Finalize blob upload
|
- `handleRequest(context)` - Handle HTTP request
|
||||||
- `putManifest(repository, reference, manifest, contentType, token)` - Upload manifest
|
- `getStorage()` - Get storage instance
|
||||||
|
- `getAuthManager()` - Get auth manager
|
||||||
|
- `getRegistry(protocol)` - Get protocol handler
|
||||||
|
|
||||||
### Content Discovery
|
#### RegistryStorage
|
||||||
|
|
||||||
- `listTags(repository, token?, pagination?)` - List all tags
|
Unified storage abstraction.
|
||||||
- `getReferrers(repository, digest, token?, artifactType?)` - Get referencing artifacts
|
|
||||||
|
|
||||||
### Content Management
|
**OCI Methods:**
|
||||||
|
- `getOciBlob(digest)` - Get blob
|
||||||
|
- `putOciBlob(digest, data)` - Store blob
|
||||||
|
- `getOciManifest(repo, digest)` - Get manifest
|
||||||
|
- `putOciManifest(repo, digest, data, type)` - Store manifest
|
||||||
|
|
||||||
- `deleteManifest(repository, digest, token)` - Delete manifest
|
**NPM Methods:**
|
||||||
- `deleteBlob(repository, digest, token)` - Delete blob
|
- `getNpmPackument(name)` - Get package metadata
|
||||||
- `deleteTag(repository, tag, token)` - Delete tag
|
- `putNpmPackument(name, data)` - Store package metadata
|
||||||
|
- `getNpmTarball(name, version)` - Get tarball
|
||||||
|
- `putNpmTarball(name, version, data)` - Store tarball
|
||||||
|
|
||||||
### Authentication
|
#### AuthManager
|
||||||
|
|
||||||
- `login(credentials)` - Get authentication token
|
Unified authentication manager.
|
||||||
- `getAuthChallenge(repository, actions)` - Generate WWW-Authenticate header
|
|
||||||
|
|
||||||
## OCI Specification Compliance
|
**Methods:**
|
||||||
|
- `authenticate(credentials)` - Validate user credentials
|
||||||
|
- `createNpmToken(userId, readonly)` - Create NPM token
|
||||||
|
- `createOciToken(userId, scopes, expiresIn)` - Create OCI JWT
|
||||||
|
- `validateToken(token, protocol)` - Validate any token
|
||||||
|
- `authorize(token, resource, action)` - Check permissions
|
||||||
|
|
||||||
This library implements:
|
### Protocol Handlers
|
||||||
|
|
||||||
- **Pull Category** (required): All manifest and blob retrieval operations
|
#### OciRegistry
|
||||||
- **Push Category**: Complete blob upload workflow with chunked and monolithic modes
|
|
||||||
- **Content Discovery**: Tag listing and referrers API
|
**Endpoints:**
|
||||||
- **Content Management**: Deletion operations for manifests, blobs, and tags
|
- `GET /v2/` - Version check
|
||||||
|
- `GET /v2/{name}/manifests/{ref}` - Get manifest
|
||||||
|
- `PUT /v2/{name}/manifests/{ref}` - Push manifest
|
||||||
|
- `GET /v2/{name}/blobs/{digest}` - Get blob
|
||||||
|
- `POST /v2/{name}/blobs/uploads/` - Initiate upload
|
||||||
|
- `GET /v2/{name}/tags/list` - List tags
|
||||||
|
- `GET /v2/{name}/referrers/{digest}` - Get referrers
|
||||||
|
|
||||||
|
#### NpmRegistry
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `GET /{package}` - Get package metadata
|
||||||
|
- `PUT /{package}` - Publish package
|
||||||
|
- `GET /{package}/-/{tarball}` - Download tarball
|
||||||
|
- `GET /-/v1/search` - Search packages
|
||||||
|
- `PUT /-/user/org.couchdb.user:{user}` - Login
|
||||||
|
- `GET /-/npm/v1/tokens` - List tokens
|
||||||
|
- `POST /-/npm/v1/tokens` - Create token
|
||||||
|
- `PUT /-/package/{pkg}/dist-tags/{tag}` - Update tag
|
||||||
|
|
||||||
|
## Storage Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bucket/
|
||||||
|
├── oci/
|
||||||
|
│ ├── blobs/
|
||||||
|
│ │ └── sha256/{hash}
|
||||||
|
│ ├── manifests/
|
||||||
|
│ │ └── {repository}/{digest}
|
||||||
|
│ └── tags/
|
||||||
|
│ └── {repository}/tags.json
|
||||||
|
└── npm/
|
||||||
|
├── packages/
|
||||||
|
│ ├── {name}/
|
||||||
|
│ │ ├── index.json # Packument
|
||||||
|
│ │ └── {name}-{ver}.tgz # Tarball
|
||||||
|
│ └── @{scope}/{name}/
|
||||||
|
│ ├── index.json
|
||||||
|
│ └── {name}-{ver}.tgz
|
||||||
|
└── users/
|
||||||
|
└── {username}.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scope Format
|
||||||
|
|
||||||
|
Unified scope format across protocols:
|
||||||
|
|
||||||
|
```
|
||||||
|
{protocol}:{type}:{name}:{action}
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
npm:package:express:read # Read express package
|
||||||
|
npm:package:*:write # Write any package
|
||||||
|
npm:*:* # Full NPM access
|
||||||
|
oci:repository:nginx:pull # Pull nginx image
|
||||||
|
oci:repository:*:push # Push any image
|
||||||
|
oci:*:* # Full OCI access
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Examples
|
||||||
|
|
||||||
|
### Express Server
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import express from 'express';
|
||||||
|
import { SmartRegistry } from '@push.rocks/smartregistry';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const registry = new SmartRegistry(config);
|
||||||
|
await registry.init();
|
||||||
|
|
||||||
|
app.all('*', async (req, res) => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: req.method,
|
||||||
|
path: req.path,
|
||||||
|
headers: req.headers as Record<string, string>,
|
||||||
|
query: req.query as Record<string, string>,
|
||||||
|
body: req.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(response.status);
|
||||||
|
Object.entries(response.headers).forEach(([key, value]) => {
|
||||||
|
res.setHeader(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.body) {
|
||||||
|
if (Buffer.isBuffer(response.body)) {
|
||||||
|
res.send(response.body);
|
||||||
|
} else {
|
||||||
|
res.json(response.body);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(5000);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Build
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
# Test
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -171,4 +465,4 @@ MIT
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See the main repository for contribution guidelines.
|
Contributions welcome! Please see the repository for guidelines.
|
||||||
|
|||||||
149
test/test.helper.ts
Normal file
149
test/test.helper.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import * as qenv from '@push.rocks/qenv';
|
||||||
|
import { SmartRegistry, IRegistryConfig } from '../ts/index.js';
|
||||||
|
|
||||||
|
const testQenv = new qenv.Qenv('./', './.nogit');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test SmartRegistry instance with both OCI and NPM enabled
|
||||||
|
*/
|
||||||
|
export async function createTestRegistry(): Promise<SmartRegistry> {
|
||||||
|
// Read S3 config from env.json
|
||||||
|
const s3AccessKey = await testQenv.getEnvVarOnDemand('S3_ACCESS_KEY');
|
||||||
|
const s3SecretKey = await testQenv.getEnvVarOnDemand('S3_SECRET_KEY');
|
||||||
|
const s3Endpoint = await testQenv.getEnvVarOnDemand('S3_ENDPOINT');
|
||||||
|
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
|
||||||
|
|
||||||
|
const config: IRegistryConfig = {
|
||||||
|
storage: {
|
||||||
|
accessKey: s3AccessKey || 'minioadmin',
|
||||||
|
accessSecret: s3SecretKey || 'minioadmin',
|
||||||
|
endpoint: s3Endpoint || 'localhost',
|
||||||
|
port: parseInt(s3Port || '9000', 10),
|
||||||
|
useSsl: false,
|
||||||
|
region: 'us-east-1',
|
||||||
|
bucketName: 'test-registry',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
jwtSecret: 'test-secret-key',
|
||||||
|
tokenStore: 'memory',
|
||||||
|
npmTokens: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
ociTokens: {
|
||||||
|
enabled: true,
|
||||||
|
realm: 'https://auth.example.com/token',
|
||||||
|
service: 'test-registry',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
oci: {
|
||||||
|
enabled: true,
|
||||||
|
basePath: '/oci',
|
||||||
|
},
|
||||||
|
npm: {
|
||||||
|
enabled: true,
|
||||||
|
basePath: '/npm',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const registry = new SmartRegistry(config);
|
||||||
|
await registry.init();
|
||||||
|
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create test authentication tokens
|
||||||
|
*/
|
||||||
|
export async function createTestTokens(registry: SmartRegistry) {
|
||||||
|
const authManager = registry.getAuthManager();
|
||||||
|
|
||||||
|
// Authenticate and create tokens
|
||||||
|
const userId = await authManager.authenticate({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpass',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('Failed to authenticate test user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create NPM token
|
||||||
|
const npmToken = await authManager.createNpmToken(userId, false);
|
||||||
|
|
||||||
|
// Create OCI token with full access
|
||||||
|
const ociToken = await authManager.createOciToken(
|
||||||
|
userId,
|
||||||
|
['oci:repository:*:*'],
|
||||||
|
3600
|
||||||
|
);
|
||||||
|
|
||||||
|
return { npmToken, ociToken, userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to calculate SHA-256 digest in OCI format
|
||||||
|
*/
|
||||||
|
export function calculateDigest(data: Buffer): string {
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
||||||
|
return `sha256:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a minimal valid OCI manifest
|
||||||
|
*/
|
||||||
|
export function createTestManifest(configDigest: string, layerDigest: string) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 2,
|
||||||
|
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
config: {
|
||||||
|
mediaType: 'application/vnd.oci.image.config.v1+json',
|
||||||
|
size: 123,
|
||||||
|
digest: configDigest,
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip',
|
||||||
|
size: 456,
|
||||||
|
digest: layerDigest,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a minimal valid NPM packument
|
||||||
|
*/
|
||||||
|
export function createTestPackument(packageName: string, version: string, tarballData: Buffer) {
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const shasum = crypto.createHash('sha1').update(tarballData).digest('hex');
|
||||||
|
const integrity = `sha512-${crypto.createHash('sha512').update(tarballData).digest('base64')}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: packageName,
|
||||||
|
versions: {
|
||||||
|
[version]: {
|
||||||
|
name: packageName,
|
||||||
|
version: version,
|
||||||
|
description: 'Test package',
|
||||||
|
main: 'index.js',
|
||||||
|
scripts: {},
|
||||||
|
dist: {
|
||||||
|
shasum: shasum,
|
||||||
|
integrity: integrity,
|
||||||
|
tarball: `http://localhost:5000/npm/${packageName}/-/${packageName}-${version}.tgz`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'dist-tags': {
|
||||||
|
latest: version,
|
||||||
|
},
|
||||||
|
_attachments: {
|
||||||
|
[`${packageName}-${version}.tgz`]: {
|
||||||
|
content_type: 'application/octet-stream',
|
||||||
|
data: tarballData.toString('base64'),
|
||||||
|
length: tarballData.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
0
test/test.npm.ts
Normal file
0
test/test.npm.ts
Normal file
297
test/test.oci.ts
Normal file
297
test/test.oci.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { SmartRegistry } from '../ts/index.js';
|
||||||
|
import { createTestRegistry, createTestTokens, calculateDigest, createTestManifest } from './test.helper.js';
|
||||||
|
|
||||||
|
let registry: SmartRegistry;
|
||||||
|
let ociToken: string;
|
||||||
|
let testBlobDigest: string;
|
||||||
|
let testConfigDigest: string;
|
||||||
|
let testManifestDigest: string;
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
const testBlobData = Buffer.from('Hello from OCI test blob!', 'utf-8');
|
||||||
|
const testConfigData = Buffer.from(JSON.stringify({ arch: 'amd64', os: 'linux' }), 'utf-8');
|
||||||
|
|
||||||
|
tap.test('OCI: should create registry instance', async () => {
|
||||||
|
registry = await createTestRegistry();
|
||||||
|
const tokens = await createTestTokens(registry);
|
||||||
|
ociToken = tokens.ociToken;
|
||||||
|
|
||||||
|
expect(registry).toBeInstanceOf(SmartRegistry);
|
||||||
|
expect(ociToken).toBeTypeOf('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should handle version check (GET /v2/)', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/oci/v2/',
|
||||||
|
headers: {},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.headers['Docker-Distribution-API-Version']).toEqual('registry/2.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should initiate blob upload (POST /v2/{name}/blobs/uploads/)', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/oci/v2/test-repo/blobs/uploads/',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(202);
|
||||||
|
expect(response.headers).toHaveProperty('Location');
|
||||||
|
expect(response.headers).toHaveProperty('Docker-Upload-UUID');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should upload blob in single PUT', async () => {
|
||||||
|
testBlobDigest = calculateDigest(testBlobData);
|
||||||
|
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/oci/v2/test-repo/blobs/uploads/',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
digest: testBlobDigest,
|
||||||
|
},
|
||||||
|
body: testBlobData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(201);
|
||||||
|
expect(response.headers).toHaveProperty('Location');
|
||||||
|
expect(response.headers['Docker-Content-Digest']).toEqual(testBlobDigest);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should upload config blob', async () => {
|
||||||
|
testConfigDigest = calculateDigest(testConfigData);
|
||||||
|
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/oci/v2/test-repo/blobs/uploads/',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
digest: testConfigDigest,
|
||||||
|
},
|
||||||
|
body: testConfigData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(201);
|
||||||
|
expect(response.headers['Docker-Content-Digest']).toEqual(testConfigDigest);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should check if blob exists (HEAD /v2/{name}/blobs/{digest})', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'HEAD',
|
||||||
|
path: `/oci/v2/test-repo/blobs/${testBlobDigest}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.headers['Content-Length']).toEqual(testBlobData.length.toString());
|
||||||
|
expect(response.headers['Docker-Content-Digest']).toEqual(testBlobDigest);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should retrieve blob (GET /v2/{name}/blobs/{digest})', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/oci/v2/test-repo/blobs/${testBlobDigest}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.body).toBeInstanceOf(Buffer);
|
||||||
|
expect((response.body as Buffer).toString('utf-8')).toEqual('Hello from OCI test blob!');
|
||||||
|
expect(response.headers['Docker-Content-Digest']).toEqual(testBlobDigest);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should upload manifest (PUT /v2/{name}/manifests/{reference})', async () => {
|
||||||
|
const manifest = createTestManifest(testConfigDigest, testBlobDigest);
|
||||||
|
const manifestJson = JSON.stringify(manifest);
|
||||||
|
const manifestBuffer = Buffer.from(manifestJson, 'utf-8');
|
||||||
|
testManifestDigest = calculateDigest(manifestBuffer);
|
||||||
|
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'PUT',
|
||||||
|
path: '/oci/v2/test-repo/manifests/v1.0.0',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
'Content-Type': 'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
body: manifestBuffer,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(201);
|
||||||
|
expect(response.headers).toHaveProperty('Location');
|
||||||
|
expect(response.headers['Docker-Content-Digest']).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should retrieve manifest by tag (GET /v2/{name}/manifests/{reference})', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/oci/v2/test-repo/manifests/v1.0.0',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
Accept: 'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.body).toBeInstanceOf(Buffer);
|
||||||
|
|
||||||
|
const manifest = JSON.parse((response.body as Buffer).toString('utf-8'));
|
||||||
|
expect(manifest.schemaVersion).toEqual(2);
|
||||||
|
expect(manifest.config.digest).toEqual(testConfigDigest);
|
||||||
|
expect(manifest.layers[0].digest).toEqual(testBlobDigest);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should retrieve manifest by digest (GET /v2/{name}/manifests/{digest})', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/oci/v2/test-repo/manifests/${testManifestDigest}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
Accept: 'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.headers['Docker-Content-Digest']).toEqual(testManifestDigest);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should check if manifest exists (HEAD /v2/{name}/manifests/{reference})', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'HEAD',
|
||||||
|
path: '/oci/v2/test-repo/manifests/v1.0.0',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
Accept: 'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.headers['Docker-Content-Digest']).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should list tags (GET /v2/{name}/tags/list)', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/oci/v2/test-repo/tags/list',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.body).toHaveProperty('tags');
|
||||||
|
|
||||||
|
const tagList = response.body as any;
|
||||||
|
expect(tagList.name).toEqual('test-repo');
|
||||||
|
expect(tagList.tags).toBeInstanceOf(Array);
|
||||||
|
expect(tagList.tags).toContain('v1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should handle pagination for tag list', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/oci/v2/test-repo/tags/list',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
n: '10',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.body).toHaveProperty('tags');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should return 404 for non-existent blob', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/oci/v2/test-repo/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(404);
|
||||||
|
expect(response.body).toHaveProperty('errors');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should return 404 for non-existent manifest', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/oci/v2/test-repo/manifests/non-existent-tag',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
Accept: 'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(404);
|
||||||
|
expect(response.body).toHaveProperty('errors');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should delete manifest (DELETE /v2/{name}/manifests/{digest})', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'DELETE',
|
||||||
|
path: `/oci/v2/test-repo/manifests/${testManifestDigest}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(202);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should delete blob (DELETE /v2/{name}/blobs/{digest})', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'DELETE',
|
||||||
|
path: `/oci/v2/test-repo/blobs/${testBlobDigest}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${ociToken}`,
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(202);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('OCI: should handle unauthorized requests', async () => {
|
||||||
|
const response = await registry.handleRequest({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/oci/v2/test-repo/manifests/v1.0.0',
|
||||||
|
headers: {
|
||||||
|
// No authorization header
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toEqual(401);
|
||||||
|
expect(response.headers['WWW-Authenticate']).toInclude('Bearer');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import * as plugins from './plugins.js';
|
|
||||||
import { IRegistryConfig, IOciManifest, IOciImageIndex, ITagList } from './interfaces.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage layer for OCI registry using SmartBucket
|
|
||||||
*/
|
|
||||||
export class RegistryStorage {
|
|
||||||
private smartBucket: plugins.smartbucket.SmartBucket;
|
|
||||||
private bucket: plugins.smartbucket.Bucket;
|
|
||||||
private bucketName: string;
|
|
||||||
|
|
||||||
constructor(private config: IRegistryConfig['storage']) {
|
|
||||||
this.bucketName = config.bucketName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the storage backend
|
|
||||||
*/
|
|
||||||
public async init() {
|
|
||||||
this.smartBucket = new plugins.smartbucket.SmartBucket({
|
|
||||||
accessKey: this.config.accessKey,
|
|
||||||
accessSecret: this.config.accessSecret,
|
|
||||||
endpoint: this.config.endpoint,
|
|
||||||
port: this.config.port || 443,
|
|
||||||
useSsl: this.config.useSsl !== false,
|
|
||||||
region: this.config.region || 'us-east-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure bucket exists
|
|
||||||
await this.smartBucket.createBucket(this.bucketName).catch(() => {
|
|
||||||
// Bucket may already exist, that's fine
|
|
||||||
});
|
|
||||||
|
|
||||||
this.bucket = await this.smartBucket.getBucketByName(this.bucketName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a blob
|
|
||||||
* @param digest - Content digest (e.g., "sha256:abc123...")
|
|
||||||
* @param data - Blob data
|
|
||||||
*/
|
|
||||||
public async putBlob(digest: string, data: Buffer): Promise<void> {
|
|
||||||
const path = this.getBlobPath(digest);
|
|
||||||
await this.bucket.fastPut({
|
|
||||||
path,
|
|
||||||
contents: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a blob
|
|
||||||
* @param digest - Content digest
|
|
||||||
* @returns Blob data or null if not found
|
|
||||||
*/
|
|
||||||
public async getBlob(digest: string): Promise<Buffer | null> {
|
|
||||||
const path = this.getBlobPath(digest);
|
|
||||||
try {
|
|
||||||
return await this.bucket.fastGet({ path });
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if blob exists
|
|
||||||
* @param digest - Content digest
|
|
||||||
* @returns true if exists
|
|
||||||
*/
|
|
||||||
public async blobExists(digest: string): Promise<boolean> {
|
|
||||||
const path = this.getBlobPath(digest);
|
|
||||||
return await this.bucket.fastExists({ path });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a blob
|
|
||||||
* @param digest - Content digest
|
|
||||||
*/
|
|
||||||
public async deleteBlob(digest: string): Promise<void> {
|
|
||||||
const path = this.getBlobPath(digest);
|
|
||||||
await this.bucket.fastRemove({ path });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a manifest
|
|
||||||
* @param repository - Repository name (e.g., "library/nginx")
|
|
||||||
* @param reference - Tag or digest
|
|
||||||
* @param manifest - Manifest content
|
|
||||||
* @param contentType - Manifest media type
|
|
||||||
*/
|
|
||||||
public async putManifest(
|
|
||||||
repository: string,
|
|
||||||
reference: string,
|
|
||||||
manifest: IOciManifest | IOciImageIndex,
|
|
||||||
contentType: string
|
|
||||||
): Promise<string> {
|
|
||||||
const manifestJson = JSON.stringify(manifest);
|
|
||||||
const manifestBuffer = Buffer.from(manifestJson, 'utf-8');
|
|
||||||
|
|
||||||
// Calculate digest
|
|
||||||
const digest = await this.calculateDigest(manifestBuffer);
|
|
||||||
|
|
||||||
// Store by digest
|
|
||||||
const digestPath = this.getManifestPath(repository, digest);
|
|
||||||
await this.bucket.fastPut({
|
|
||||||
path: digestPath,
|
|
||||||
contents: manifestBuffer,
|
|
||||||
meta: { 'Content-Type': contentType },
|
|
||||||
});
|
|
||||||
|
|
||||||
// If reference is a tag (not a digest), create/update tag mapping
|
|
||||||
if (!reference.startsWith('sha256:')) {
|
|
||||||
await this.putTag(repository, reference, digest);
|
|
||||||
}
|
|
||||||
|
|
||||||
return digest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a manifest
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param reference - Tag or digest
|
|
||||||
* @returns Manifest data and content type, or null if not found
|
|
||||||
*/
|
|
||||||
public async getManifest(
|
|
||||||
repository: string,
|
|
||||||
reference: string
|
|
||||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
|
||||||
let digest = reference;
|
|
||||||
|
|
||||||
// If reference is a tag, resolve to digest
|
|
||||||
if (!reference.startsWith('sha256:')) {
|
|
||||||
const resolvedDigest = await this.getTagDigest(repository, reference);
|
|
||||||
if (!resolvedDigest) return null;
|
|
||||||
digest = resolvedDigest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = this.getManifestPath(repository, digest);
|
|
||||||
try {
|
|
||||||
const data = await this.bucket.fastGet({ path });
|
|
||||||
// TODO: Retrieve content type from metadata if SmartBucket supports it
|
|
||||||
const contentType = 'application/vnd.oci.image.manifest.v1+json';
|
|
||||||
return { data, contentType };
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if manifest exists
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param reference - Tag or digest
|
|
||||||
* @returns true if exists
|
|
||||||
*/
|
|
||||||
public async manifestExists(repository: string, reference: string): Promise<boolean> {
|
|
||||||
let digest = reference;
|
|
||||||
|
|
||||||
// If reference is a tag, resolve to digest
|
|
||||||
if (!reference.startsWith('sha256:')) {
|
|
||||||
const resolvedDigest = await this.getTagDigest(repository, reference);
|
|
||||||
if (!resolvedDigest) return false;
|
|
||||||
digest = resolvedDigest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = this.getManifestPath(repository, digest);
|
|
||||||
return await this.bucket.fastExists({ path });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a manifest
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param digest - Manifest digest (must be digest, not tag)
|
|
||||||
*/
|
|
||||||
public async deleteManifest(repository: string, digest: string): Promise<void> {
|
|
||||||
const path = this.getManifestPath(repository, digest);
|
|
||||||
await this.bucket.fastRemove({ path });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store tag mapping
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param tag - Tag name
|
|
||||||
* @param digest - Manifest digest
|
|
||||||
*/
|
|
||||||
public async putTag(repository: string, tag: string, digest: string): Promise<void> {
|
|
||||||
const tags = await this.getTags(repository);
|
|
||||||
tags[tag] = digest;
|
|
||||||
|
|
||||||
const path = this.getTagsPath(repository);
|
|
||||||
await this.bucket.fastPut({
|
|
||||||
path,
|
|
||||||
contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get digest for a tag
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param tag - Tag name
|
|
||||||
* @returns Digest or null if tag doesn't exist
|
|
||||||
*/
|
|
||||||
public async getTagDigest(repository: string, tag: string): Promise<string | null> {
|
|
||||||
const tags = await this.getTags(repository);
|
|
||||||
return tags[tag] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all tags for a repository
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @returns Tag list
|
|
||||||
*/
|
|
||||||
public async listTags(repository: string): Promise<string[]> {
|
|
||||||
const tags = await this.getTags(repository);
|
|
||||||
return Object.keys(tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a tag
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param tag - Tag name
|
|
||||||
*/
|
|
||||||
public async deleteTag(repository: string, tag: string): Promise<void> {
|
|
||||||
const tags = await this.getTags(repository);
|
|
||||||
delete tags[tag];
|
|
||||||
|
|
||||||
const path = this.getTagsPath(repository);
|
|
||||||
await this.bucket.fastPut({
|
|
||||||
path,
|
|
||||||
contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all manifests that reference a specific digest (referrers API)
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param digest - Subject digest
|
|
||||||
* @returns Array of manifest digests
|
|
||||||
*/
|
|
||||||
public async getReferrers(repository: string, digest: string): Promise<string[]> {
|
|
||||||
// This is a simplified implementation
|
|
||||||
// In production, you'd want to maintain an index
|
|
||||||
const referrersPath = this.getReferrersPath(repository, digest);
|
|
||||||
try {
|
|
||||||
const data = await this.bucket.fastGet({ path: referrersPath });
|
|
||||||
const referrers = JSON.parse(data.toString('utf-8'));
|
|
||||||
return referrers;
|
|
||||||
} catch (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a referrer relationship
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param subjectDigest - Digest being referenced
|
|
||||||
* @param referrerDigest - Digest of the referrer
|
|
||||||
*/
|
|
||||||
public async addReferrer(
|
|
||||||
repository: string,
|
|
||||||
subjectDigest: string,
|
|
||||||
referrerDigest: string
|
|
||||||
): Promise<void> {
|
|
||||||
const referrers = await this.getReferrers(repository, subjectDigest);
|
|
||||||
if (!referrers.includes(referrerDigest)) {
|
|
||||||
referrers.push(referrerDigest);
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = this.getReferrersPath(repository, subjectDigest);
|
|
||||||
await this.bucket.fastPut({
|
|
||||||
path,
|
|
||||||
contents: Buffer.from(JSON.stringify(referrers, null, 2), 'utf-8'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper methods
|
|
||||||
|
|
||||||
private getBlobPath(digest: string): string {
|
|
||||||
// Remove algorithm prefix for path (sha256:abc -> abc)
|
|
||||||
const hash = digest.split(':')[1];
|
|
||||||
return `blobs/sha256/${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getManifestPath(repository: string, digest: string): string {
|
|
||||||
const hash = digest.split(':')[1];
|
|
||||||
return `manifests/${repository}/${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTagsPath(repository: string): string {
|
|
||||||
return `tags/${repository}/tags.json`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getReferrersPath(repository: string, digest: string): string {
|
|
||||||
const hash = digest.split(':')[1];
|
|
||||||
return `referrers/${repository}/${hash}.json`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getTags(repository: string): Promise<{ [tag: string]: string }> {
|
|
||||||
const path = this.getTagsPath(repository);
|
|
||||||
try {
|
|
||||||
const data = await this.bucket.fastGet({ path });
|
|
||||||
return JSON.parse(data.toString('utf-8'));
|
|
||||||
} catch (error) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async calculateDigest(data: Buffer): Promise<string> {
|
|
||||||
const crypto = await import('crypto');
|
|
||||||
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
|
||||||
return `sha256:${hash}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,747 +1,118 @@
|
|||||||
import * as plugins from './plugins.js';
|
import { RegistryStorage } from './core/classes.registrystorage.js';
|
||||||
import { RegistryStorage } from './classes.registrystorage.js';
|
import { AuthManager } from './core/classes.authmanager.js';
|
||||||
import {
|
import { BaseRegistry } from './core/classes.baseregistry.js';
|
||||||
IRegistryConfig,
|
import type { IRegistryConfig, IRequestContext, IResponse } from './core/interfaces.core.js';
|
||||||
IUploadSession,
|
import { OciRegistry } from './oci/classes.ociregistry.js';
|
||||||
IOciManifest,
|
import { NpmRegistry } from './npm/classes.npmregistry.js';
|
||||||
IOciImageIndex,
|
|
||||||
ITagList,
|
|
||||||
IReferrersResponse,
|
|
||||||
IRegistryError,
|
|
||||||
IPaginationOptions,
|
|
||||||
TRegistryAction,
|
|
||||||
} from './interfaces.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main OCI Distribution Specification compliant registry class
|
* Main registry orchestrator
|
||||||
* This class provides all the methods needed to implement an OCI registry
|
* Routes requests to appropriate protocol handlers (OCI or NPM)
|
||||||
* and can be integrated into any HTTP server
|
|
||||||
*/
|
*/
|
||||||
export class SmartRegistry {
|
export class SmartRegistry {
|
||||||
private storage: RegistryStorage;
|
private storage: RegistryStorage;
|
||||||
|
private authManager: AuthManager;
|
||||||
|
private registries: Map<string, BaseRegistry> = new Map();
|
||||||
private config: IRegistryConfig;
|
private config: IRegistryConfig;
|
||||||
private uploadSessions: Map<string, IUploadSession> = new Map();
|
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
|
||||||
constructor(config: IRegistryConfig) {
|
constructor(config: IRegistryConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.storage = new RegistryStorage(config.storage);
|
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> {
|
public async init(): Promise<void> {
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
// Initialize storage
|
||||||
await this.storage.init();
|
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;
|
this.initialized = true;
|
||||||
|
|
||||||
// Start cleanup of stale upload sessions
|
|
||||||
this.startUploadSessionCleanup();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// PULL OPERATIONS (Required by OCI spec)
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /v2/{name}/manifests/{reference}
|
* Handle an incoming HTTP request
|
||||||
* Retrieve a manifest by tag or digest
|
* Routes to the appropriate protocol handler based on path
|
||||||
* @param repository - Repository name (e.g., "library/nginx")
|
|
||||||
* @param reference - Tag name or digest
|
|
||||||
* @param token - Optional bearer token for authentication
|
|
||||||
* @returns Manifest content and metadata
|
|
||||||
*/
|
*/
|
||||||
public async getManifest(
|
public async handleRequest(context: IRequestContext): Promise<IResponse> {
|
||||||
repository: string,
|
const path = context.path;
|
||||||
reference: string,
|
|
||||||
token?: string
|
// Route to OCI registry
|
||||||
): Promise<{ data: Buffer; contentType: string; digest: string } | IRegistryError> {
|
if (this.config.oci?.enabled && path.startsWith(this.config.oci.basePath)) {
|
||||||
// Check authorization
|
const ociRegistry = this.registries.get('oci');
|
||||||
if (token) {
|
if (ociRegistry) {
|
||||||
const authorized = await this.config.authCallback(token, repository, 'pull');
|
return ociRegistry.handleRequest(context);
|
||||||
if (!authorized) {
|
|
||||||
return this.createError('DENIED', 'Insufficient permissions');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.storage.getManifest(repository, reference);
|
// Route to NPM registry
|
||||||
if (!result) {
|
if (this.config.npm?.enabled && path.startsWith(this.config.npm.basePath)) {
|
||||||
return this.createError('MANIFEST_UNKNOWN', 'Manifest not found');
|
const npmRegistry = this.registries.get('npm');
|
||||||
|
if (npmRegistry) {
|
||||||
|
return npmRegistry.handleRequest(context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate digest if not already known
|
// No matching registry
|
||||||
const digest = await this.calculateDigest(result.data);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: result.data,
|
status: 404,
|
||||||
contentType: result.contentType,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
digest,
|
body: {
|
||||||
|
error: 'NOT_FOUND',
|
||||||
|
message: 'No registry handler for this path',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HEAD /v2/{name}/manifests/{reference}
|
* Get the storage instance (for testing/advanced use)
|
||||||
* Check if a manifest exists without downloading it
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param reference - Tag name or digest
|
|
||||||
* @param token - Optional bearer token
|
|
||||||
* @returns Metadata if exists, error otherwise
|
|
||||||
*/
|
*/
|
||||||
public async headManifest(
|
public getStorage(): RegistryStorage {
|
||||||
repository: string,
|
return this.storage;
|
||||||
reference: string,
|
|
||||||
token?: string
|
|
||||||
): Promise<{ exists: true; digest: string; contentType: string } | IRegistryError> {
|
|
||||||
// Check authorization
|
|
||||||
if (token) {
|
|
||||||
const authorized = await this.config.authCallback(token, repository, 'pull');
|
|
||||||
if (!authorized) {
|
|
||||||
return this.createError('DENIED', 'Insufficient permissions');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const exists = await this.storage.manifestExists(repository, reference);
|
|
||||||
if (!exists) {
|
|
||||||
return this.createError('MANIFEST_UNKNOWN', 'Manifest not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get manifest to calculate digest and content type
|
|
||||||
const result = await this.storage.getManifest(repository, reference);
|
|
||||||
if (!result) {
|
|
||||||
return this.createError('MANIFEST_UNKNOWN', 'Manifest not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const digest = await this.calculateDigest(result.data);
|
|
||||||
|
|
||||||
return {
|
|
||||||
exists: true,
|
|
||||||
digest,
|
|
||||||
contentType: result.contentType,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /v2/{name}/blobs/{digest}
|
* Get the auth manager instance (for testing/advanced use)
|
||||||
* Download a blob
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param digest - Blob digest
|
|
||||||
* @param token - Optional bearer token
|
|
||||||
* @param range - Optional HTTP range header (e.g., "bytes=0-1023")
|
|
||||||
* @returns Blob data
|
|
||||||
*/
|
*/
|
||||||
public async getBlob(
|
public getAuthManager(): AuthManager {
|
||||||
repository: string,
|
return this.authManager;
|
||||||
digest: string,
|
|
||||||
token?: string,
|
|
||||||
range?: string
|
|
||||||
): Promise<{ data: Buffer; contentType: string } | IRegistryError> {
|
|
||||||
// Check authorization
|
|
||||||
if (token) {
|
|
||||||
const authorized = await this.config.authCallback(token, repository, 'pull');
|
|
||||||
if (!authorized) {
|
|
||||||
return this.createError('DENIED', 'Insufficient permissions');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await this.storage.getBlob(digest);
|
|
||||||
if (!data) {
|
|
||||||
return this.createError('BLOB_UNKNOWN', 'Blob not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle range requests
|
|
||||||
let responseData = data;
|
|
||||||
if (range) {
|
|
||||||
const rangeMatch = range.match(/bytes=(\d+)-(\d*)/);
|
|
||||||
if (rangeMatch) {
|
|
||||||
const start = parseInt(rangeMatch[1], 10);
|
|
||||||
const end = rangeMatch[2] ? parseInt(rangeMatch[2], 10) + 1 : data.length;
|
|
||||||
responseData = data.slice(start, end);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: responseData,
|
|
||||||
contentType: 'application/octet-stream',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HEAD /v2/{name}/blobs/{digest}
|
* Get a specific registry handler
|
||||||
* Check if a blob exists
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param digest - Blob digest
|
|
||||||
* @param token - Optional bearer token
|
|
||||||
* @returns Metadata if exists
|
|
||||||
*/
|
*/
|
||||||
public async headBlob(
|
public getRegistry(protocol: 'oci' | 'npm'): BaseRegistry | undefined {
|
||||||
repository: string,
|
return this.registries.get(protocol);
|
||||||
digest: string,
|
|
||||||
token?: string
|
|
||||||
): Promise<{ exists: true; size: number } | IRegistryError> {
|
|
||||||
// Check authorization
|
|
||||||
if (token) {
|
|
||||||
const authorized = await this.config.authCallback(token, repository, 'pull');
|
|
||||||
if (!authorized) {
|
|
||||||
return this.createError('DENIED', 'Insufficient permissions');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const exists = await this.storage.blobExists(digest);
|
|
||||||
if (!exists) {
|
|
||||||
return this.createError('BLOB_UNKNOWN', 'Blob not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get blob to determine size
|
|
||||||
const data = await this.storage.getBlob(digest);
|
|
||||||
if (!data) {
|
|
||||||
return this.createError('BLOB_UNKNOWN', 'Blob not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
exists: true,
|
|
||||||
size: data.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// PUSH OPERATIONS
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /v2/{name}/blobs/uploads/
|
|
||||||
* Initiate a blob upload session
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param token - Bearer token
|
|
||||||
* @param mountDigest - Optional digest to mount from another repository
|
|
||||||
* @param fromRepository - Source repository for mount
|
|
||||||
* @returns Upload session ID and location
|
|
||||||
*/
|
|
||||||
public async initiateUpload(
|
|
||||||
repository: string,
|
|
||||||
token: string,
|
|
||||||
mountDigest?: string,
|
|
||||||
fromRepository?: string
|
|
||||||
): Promise<{ uploadId: string; location: string } | IRegistryError> {
|
|
||||||
// Check authorization
|
|
||||||
const authorized = await this.config.authCallback(token, repository, 'push');
|
|
||||||
if (!authorized) {
|
|
||||||
return this.createError('DENIED', 'Insufficient permissions');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle blob mount if requested
|
|
||||||
if (mountDigest && fromRepository) {
|
|
||||||
const mountResult = await this.mountBlob(
|
|
||||||
repository,
|
|
||||||
mountDigest,
|
|
||||||
fromRepository,
|
|
||||||
token
|
|
||||||
);
|
|
||||||
if ('location' in mountResult) {
|
|
||||||
return mountResult;
|
|
||||||
}
|
|
||||||
// If mount fails, continue with normal upload
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create upload session
|
|
||||||
const uploadId = this.generateUploadId();
|
|
||||||
const session: IUploadSession = {
|
|
||||||
uploadId,
|
|
||||||
repository,
|
|
||||||
chunks: [],
|
|
||||||
totalSize: 0,
|
|
||||||
createdAt: new Date(),
|
|
||||||
lastActivity: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.uploadSessions.set(uploadId, session);
|
|
||||||
|
|
||||||
return {
|
|
||||||
uploadId,
|
|
||||||
location: `/v2/${repository}/blobs/uploads/${uploadId}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /v2/{name}/blobs/uploads/{uuid}
|
* Check if the registry is initialized
|
||||||
* Upload a chunk of data to an upload session
|
|
||||||
* @param uploadId - Upload session ID
|
|
||||||
* @param data - Chunk data
|
|
||||||
* @param contentRange - Content-Range header (e.g., "0-1023")
|
|
||||||
* @param token - Bearer token
|
|
||||||
* @returns Updated upload status
|
|
||||||
*/
|
*/
|
||||||
public async uploadChunk(
|
public isInitialized(): boolean {
|
||||||
uploadId: string,
|
return this.initialized;
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IAuthConfig, IAuthToken, ICredentials, TRegistryProtocol } from './interfaces.core.js';
|
import type { IAuthConfig, IAuthToken, ICredentials, TRegistryProtocol } from './interfaces.core.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified authentication manager for all registry protocols
|
* Unified authentication manager for all registry protocols
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IRequestContext, IResponse, IAuthToken } from './interfaces.core.js';
|
import type { IRequestContext, IResponse, IAuthToken } from './interfaces.core.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract base class for all registry protocol implementations
|
* Abstract base class for all registry protocol implementations
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { IStorageConfig, IStorageBackend } from './interfaces.core.js';
|
import type { IStorageConfig, IStorageBackend } from './interfaces.core.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage abstraction layer for registry
|
* Storage abstraction layer for registry
|
||||||
@@ -54,10 +54,10 @@ export class RegistryStorage implements IStorageBackend {
|
|||||||
data: Buffer,
|
data: Buffer,
|
||||||
metadata?: Record<string, string>
|
metadata?: Record<string, string>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Note: SmartBucket doesn't support metadata yet
|
||||||
await this.bucket.fastPut({
|
await this.bucket.fastPut({
|
||||||
path: key,
|
path: key,
|
||||||
contents: data,
|
contents: data,
|
||||||
meta: metadata,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export class RegistryStorage implements IStorageBackend {
|
|||||||
if (!dir) return [];
|
if (!dir) return [];
|
||||||
|
|
||||||
const files = await dir.listFiles();
|
const files = await dir.listFiles();
|
||||||
return files.map(f => f.path);
|
return files.map(f => f.getBasePath());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
18
ts/index.ts
18
ts/index.ts
@@ -1,8 +1,16 @@
|
|||||||
import * as plugins from './plugins.js';
|
/**
|
||||||
|
* @push.rocks/smartregistry
|
||||||
|
* Composable registry supporting OCI and NPM protocols
|
||||||
|
*/
|
||||||
|
|
||||||
// Export main classes
|
// Main orchestrator
|
||||||
export { SmartRegistry } from './classes.smartregistry.js';
|
export { SmartRegistry } from './classes.smartregistry.js';
|
||||||
export { RegistryStorage } from './classes.registrystorage.js';
|
|
||||||
|
|
||||||
// Export interfaces and types
|
// Core infrastructure
|
||||||
export * from './interfaces.js';
|
export * from './core/index.js';
|
||||||
|
|
||||||
|
// OCI Registry
|
||||||
|
export * from './oci/index.js';
|
||||||
|
|
||||||
|
// NPM Registry
|
||||||
|
export * from './npm/index.js';
|
||||||
|
|||||||
197
ts/interfaces.ts
197
ts/interfaces.ts
@@ -1,197 +0,0 @@
|
|||||||
/**
|
|
||||||
* Interfaces and types for OCI Distribution Specification compliant registry
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Credentials for authentication
|
|
||||||
*/
|
|
||||||
export interface IRegistryCredentials {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Actions that can be performed on a repository
|
|
||||||
*/
|
|
||||||
export type TRegistryAction = 'pull' | 'push' | 'delete' | '*';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT token structure for OCI registry authentication
|
|
||||||
*/
|
|
||||||
export interface IRegistryToken {
|
|
||||||
/** Issuer */
|
|
||||||
iss: string;
|
|
||||||
/** Subject (user identifier) */
|
|
||||||
sub: string;
|
|
||||||
/** Audience (service name) */
|
|
||||||
aud: string;
|
|
||||||
/** Expiration timestamp */
|
|
||||||
exp: number;
|
|
||||||
/** Not before timestamp */
|
|
||||||
nbf: number;
|
|
||||||
/** Issued at timestamp */
|
|
||||||
iat: number;
|
|
||||||
/** JWT ID */
|
|
||||||
jti?: string;
|
|
||||||
/** Access permissions */
|
|
||||||
access: Array<{
|
|
||||||
type: 'repository' | 'registry';
|
|
||||||
name: string;
|
|
||||||
actions: TRegistryAction[];
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback function for user login - returns JWT token
|
|
||||||
* @param credentials - User credentials
|
|
||||||
* @returns JWT token string
|
|
||||||
*/
|
|
||||||
export type TLoginCallback = (
|
|
||||||
credentials: IRegistryCredentials
|
|
||||||
) => Promise<string>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback function for authorization check
|
|
||||||
* @param token - JWT token string
|
|
||||||
* @param repository - Repository name (e.g., "library/nginx")
|
|
||||||
* @param action - Action to perform
|
|
||||||
* @returns true if authorized, false otherwise
|
|
||||||
*/
|
|
||||||
export type TAuthCallback = (
|
|
||||||
token: string,
|
|
||||||
repository: string,
|
|
||||||
action: TRegistryAction
|
|
||||||
) => Promise<boolean>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for the registry
|
|
||||||
*/
|
|
||||||
export interface IRegistryConfig {
|
|
||||||
/** Storage bucket configuration */
|
|
||||||
storage: {
|
|
||||||
accessKey: string;
|
|
||||||
accessSecret: string;
|
|
||||||
endpoint: string;
|
|
||||||
port?: number;
|
|
||||||
useSsl?: boolean;
|
|
||||||
region?: string;
|
|
||||||
bucketName: string;
|
|
||||||
};
|
|
||||||
/** Service name for token authentication */
|
|
||||||
serviceName: string;
|
|
||||||
/** Token realm (authorization server URL) */
|
|
||||||
tokenRealm: string;
|
|
||||||
/** Login callback */
|
|
||||||
loginCallback: TLoginCallback;
|
|
||||||
/** Authorization callback */
|
|
||||||
authCallback: TAuthCallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OCI manifest structure
|
|
||||||
*/
|
|
||||||
export interface IOciManifest {
|
|
||||||
schemaVersion: number;
|
|
||||||
mediaType: string;
|
|
||||||
config: {
|
|
||||||
mediaType: string;
|
|
||||||
size: number;
|
|
||||||
digest: string;
|
|
||||||
};
|
|
||||||
layers: Array<{
|
|
||||||
mediaType: string;
|
|
||||||
size: number;
|
|
||||||
digest: string;
|
|
||||||
urls?: string[];
|
|
||||||
}>;
|
|
||||||
subject?: {
|
|
||||||
mediaType: string;
|
|
||||||
size: number;
|
|
||||||
digest: string;
|
|
||||||
};
|
|
||||||
annotations?: { [key: string]: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OCI Image Index (manifest list)
|
|
||||||
*/
|
|
||||||
export interface IOciImageIndex {
|
|
||||||
schemaVersion: number;
|
|
||||||
mediaType: string;
|
|
||||||
manifests: Array<{
|
|
||||||
mediaType: string;
|
|
||||||
size: number;
|
|
||||||
digest: string;
|
|
||||||
platform?: {
|
|
||||||
architecture: string;
|
|
||||||
os: string;
|
|
||||||
'os.version'?: string;
|
|
||||||
'os.features'?: string[];
|
|
||||||
variant?: string;
|
|
||||||
features?: string[];
|
|
||||||
};
|
|
||||||
annotations?: { [key: string]: string };
|
|
||||||
}>;
|
|
||||||
subject?: {
|
|
||||||
mediaType: string;
|
|
||||||
size: number;
|
|
||||||
digest: string;
|
|
||||||
};
|
|
||||||
annotations?: { [key: string]: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload session for chunked blob uploads
|
|
||||||
*/
|
|
||||||
export interface IUploadSession {
|
|
||||||
uploadId: string;
|
|
||||||
repository: string;
|
|
||||||
chunks: Buffer[];
|
|
||||||
totalSize: number;
|
|
||||||
createdAt: Date;
|
|
||||||
lastActivity: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tag list response
|
|
||||||
*/
|
|
||||||
export interface ITagList {
|
|
||||||
name: string;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Referrers response
|
|
||||||
*/
|
|
||||||
export interface IReferrersResponse {
|
|
||||||
schemaVersion: number;
|
|
||||||
mediaType: string;
|
|
||||||
manifests: Array<{
|
|
||||||
mediaType: string;
|
|
||||||
size: number;
|
|
||||||
digest: string;
|
|
||||||
artifactType?: string;
|
|
||||||
annotations?: { [key: string]: string };
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registry error response
|
|
||||||
*/
|
|
||||||
export interface IRegistryError {
|
|
||||||
errors: Array<{
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
detail?: any;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pagination options for listing
|
|
||||||
*/
|
|
||||||
export interface IPaginationOptions {
|
|
||||||
/** Maximum number of results to return */
|
|
||||||
n?: number;
|
|
||||||
/** Last entry from previous request */
|
|
||||||
last?: string;
|
|
||||||
}
|
|
||||||
707
ts/npm/classes.npmregistry.ts
Normal file
707
ts/npm/classes.npmregistry.ts
Normal file
@@ -0,0 +1,707 @@
|
|||||||
|
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 } from '../core/interfaces.core.js';
|
||||||
|
import type {
|
||||||
|
IPackument,
|
||||||
|
INpmVersion,
|
||||||
|
IPublishRequest,
|
||||||
|
ISearchResponse,
|
||||||
|
ISearchResult,
|
||||||
|
ITokenListResponse,
|
||||||
|
ITokenCreateRequest,
|
||||||
|
IUserAuthRequest,
|
||||||
|
INpmError,
|
||||||
|
} from './interfaces.npm.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NPM Registry implementation
|
||||||
|
* Compliant with npm registry API
|
||||||
|
*/
|
||||||
|
export class NpmRegistry extends BaseRegistry {
|
||||||
|
private storage: RegistryStorage;
|
||||||
|
private authManager: AuthManager;
|
||||||
|
private basePath: string = '/npm';
|
||||||
|
private registryUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
storage: RegistryStorage,
|
||||||
|
authManager: AuthManager,
|
||||||
|
basePath: string = '/npm',
|
||||||
|
registryUrl: string = 'http://localhost:5000/npm'
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.storage = storage;
|
||||||
|
this.authManager = authManager;
|
||||||
|
this.basePath = basePath;
|
||||||
|
this.registryUrl = registryUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
// NPM registry initialization
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBasePath(): string {
|
||||||
|
return this.basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleRequest(context: IRequestContext): Promise<IResponse> {
|
||||||
|
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, 'npm') : null;
|
||||||
|
|
||||||
|
// Registry root
|
||||||
|
if (path === '/' || path === '') {
|
||||||
|
return this.handleRegistryInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search: /-/v1/search
|
||||||
|
if (path.startsWith('/-/v1/search')) {
|
||||||
|
return this.handleSearch(context.query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User authentication: /-/user/org.couchdb.user:{username}
|
||||||
|
const userMatch = path.match(/^\/-\/user\/org\.couchdb\.user:(.+)$/);
|
||||||
|
if (userMatch) {
|
||||||
|
return this.handleUserAuth(context.method, userMatch[1], context.body, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token operations: /-/npm/v1/tokens
|
||||||
|
if (path.startsWith('/-/npm/v1/tokens')) {
|
||||||
|
return this.handleTokens(context.method, path, context.body, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dist-tags: /-/package/{package}/dist-tags
|
||||||
|
const distTagsMatch = path.match(/^\/-\/package\/(@?[^\/]+(?:\/[^\/]+)?)\/dist-tags(?:\/(.+))?$/);
|
||||||
|
if (distTagsMatch) {
|
||||||
|
const [, packageName, tag] = distTagsMatch;
|
||||||
|
return this.handleDistTags(context.method, packageName, tag, context.body, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tarball download: /{package}/-/{filename}.tgz
|
||||||
|
const tarballMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/(.+\.tgz)$/);
|
||||||
|
if (tarballMatch) {
|
||||||
|
const [, packageName, filename] = tarballMatch;
|
||||||
|
return this.handleTarballDownload(packageName, filename, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package operations: /{package}
|
||||||
|
const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/);
|
||||||
|
if (packageMatch) {
|
||||||
|
const packageName = packageMatch[1];
|
||||||
|
return this.handlePackage(context.method, packageName, context.body, context.query, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package version: /{package}/{version}
|
||||||
|
const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/);
|
||||||
|
if (versionMatch) {
|
||||||
|
const [, packageName, version] = versionMatch;
|
||||||
|
return this.handlePackageVersion(packageName, version, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: this.createError('E404', 'Not found'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async checkPermission(
|
||||||
|
token: IAuthToken | null,
|
||||||
|
resource: string,
|
||||||
|
action: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!token) return false;
|
||||||
|
return this.authManager.authorize(token, `npm:package:${resource}`, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// REQUEST HANDLERS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
private handleRegistryInfo(): IResponse {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: {
|
||||||
|
db_name: 'registry',
|
||||||
|
doc_count: 0,
|
||||||
|
doc_del_count: 0,
|
||||||
|
update_seq: 0,
|
||||||
|
purge_seq: 0,
|
||||||
|
compact_running: false,
|
||||||
|
disk_size: 0,
|
||||||
|
data_size: 0,
|
||||||
|
instance_start_time: Date.now().toString(),
|
||||||
|
disk_format_version: 0,
|
||||||
|
committed_update_seq: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePackage(
|
||||||
|
method: string,
|
||||||
|
packageName: string,
|
||||||
|
body: any,
|
||||||
|
query: Record<string, string>,
|
||||||
|
token: IAuthToken | null
|
||||||
|
): Promise<IResponse> {
|
||||||
|
switch (method) {
|
||||||
|
case 'GET':
|
||||||
|
return this.getPackument(packageName, token, query);
|
||||||
|
case 'PUT':
|
||||||
|
return this.publishPackage(packageName, body, token);
|
||||||
|
case 'DELETE':
|
||||||
|
return this.unpublishPackage(packageName, token);
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
status: 405,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EBADREQUEST', 'Method not allowed'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPackument(
|
||||||
|
packageName: string,
|
||||||
|
token: IAuthToken | null,
|
||||||
|
query: Record<string, string>
|
||||||
|
): Promise<IResponse> {
|
||||||
|
const packument = await this.storage.getNpmPackument(packageName);
|
||||||
|
if (!packument) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: this.createError('E404', `Package '${packageName}' not found`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if abbreviated version requested
|
||||||
|
const accept = query['accept'] || '';
|
||||||
|
if (accept.includes('application/vnd.npm.install-v1+json')) {
|
||||||
|
// Return abbreviated packument
|
||||||
|
const abbreviated = {
|
||||||
|
name: packument.name,
|
||||||
|
modified: packument.time?.modified || new Date().toISOString(),
|
||||||
|
'dist-tags': packument['dist-tags'],
|
||||||
|
versions: packument.versions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/vnd.npm.install-v1+json' },
|
||||||
|
body: abbreviated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: packument,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePackageVersion(
|
||||||
|
packageName: string,
|
||||||
|
version: string,
|
||||||
|
token: IAuthToken | null
|
||||||
|
): Promise<IResponse> {
|
||||||
|
const packument = await this.storage.getNpmPackument(packageName);
|
||||||
|
if (!packument) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: this.createError('E404', 'Package not found'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve version (could be "latest" or actual version)
|
||||||
|
let actualVersion = version;
|
||||||
|
if (version === 'latest') {
|
||||||
|
actualVersion = packument['dist-tags']?.latest;
|
||||||
|
if (!actualVersion) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('E404', 'No latest version'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionData = packument.versions[actualVersion];
|
||||||
|
if (!versionData) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('E404', 'Version not found'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: versionData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async publishPackage(
|
||||||
|
packageName: string,
|
||||||
|
body: IPublishRequest,
|
||||||
|
token: IAuthToken | null
|
||||||
|
): Promise<IResponse> {
|
||||||
|
if (!await this.checkPermission(token, packageName, 'write')) {
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EUNAUTHORIZED', 'Unauthorized'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || !body.versions || !body._attachments) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EBADREQUEST', 'Invalid publish request'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing packument or create new one
|
||||||
|
let packument = await this.storage.getNpmPackument(packageName);
|
||||||
|
const isNew = !packument;
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
packument = {
|
||||||
|
_id: packageName,
|
||||||
|
name: packageName,
|
||||||
|
description: body.description,
|
||||||
|
'dist-tags': body['dist-tags'] || { latest: Object.keys(body.versions)[0] },
|
||||||
|
versions: {},
|
||||||
|
time: {
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
modified: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
maintainers: body.maintainers || [],
|
||||||
|
readme: body.readme,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each new version
|
||||||
|
for (const [version, versionData] of Object.entries(body.versions)) {
|
||||||
|
// Check if version already exists
|
||||||
|
if (packument.versions[version]) {
|
||||||
|
return {
|
||||||
|
status: 403,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EPUBLISHCONFLICT', `Version ${version} already exists`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find attachment for this version
|
||||||
|
const attachmentKey = Object.keys(body._attachments).find(key =>
|
||||||
|
key.includes(version)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!attachmentKey) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EBADREQUEST', `No tarball for version ${version}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = body._attachments[attachmentKey];
|
||||||
|
|
||||||
|
// Decode base64 tarball
|
||||||
|
const tarballBuffer = Buffer.from(attachment.data, 'base64');
|
||||||
|
|
||||||
|
// Calculate shasum
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const shasum = crypto.createHash('sha1').update(tarballBuffer).digest('hex');
|
||||||
|
const integrity = `sha512-${crypto.createHash('sha512').update(tarballBuffer).digest('base64')}`;
|
||||||
|
|
||||||
|
// Store tarball
|
||||||
|
await this.storage.putNpmTarball(packageName, version, tarballBuffer);
|
||||||
|
|
||||||
|
// Update version data with dist info
|
||||||
|
const safeName = packageName.replace('@', '').replace('/', '-');
|
||||||
|
versionData.dist = {
|
||||||
|
tarball: `${this.registryUrl}/${packageName}/-/${safeName}-${version}.tgz`,
|
||||||
|
shasum,
|
||||||
|
integrity,
|
||||||
|
fileCount: 0,
|
||||||
|
unpackedSize: tarballBuffer.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
versionData._id = `${packageName}@${version}`;
|
||||||
|
versionData._npmUser = token ? { name: token.userId, email: '' } : undefined;
|
||||||
|
|
||||||
|
// Add version to packument
|
||||||
|
packument.versions[version] = versionData;
|
||||||
|
if (packument.time) {
|
||||||
|
packument.time[version] = new Date().toISOString();
|
||||||
|
packument.time.modified = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update dist-tags
|
||||||
|
if (body['dist-tags']) {
|
||||||
|
packument['dist-tags'] = { ...packument['dist-tags'], ...body['dist-tags'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save packument
|
||||||
|
await this.storage.putNpmPackument(packageName, packument);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: { ok: true, id: packageName, rev: packument._rev || '1-' + Date.now() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async unpublishPackage(
|
||||||
|
packageName: string,
|
||||||
|
token: IAuthToken | null
|
||||||
|
): Promise<IResponse> {
|
||||||
|
if (!await this.checkPermission(token, packageName, 'delete')) {
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EUNAUTHORIZED', 'Unauthorized'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const packument = await this.storage.getNpmPackument(packageName);
|
||||||
|
if (!packument) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('E404', 'Package not found'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all tarballs
|
||||||
|
for (const version of Object.keys(packument.versions)) {
|
||||||
|
await this.storage.deleteNpmTarball(packageName, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete packument
|
||||||
|
await this.storage.deleteNpmPackument(packageName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: { ok: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTarballDownload(
|
||||||
|
packageName: string,
|
||||||
|
filename: string,
|
||||||
|
token: IAuthToken | null
|
||||||
|
): Promise<IResponse> {
|
||||||
|
// Extract version from filename: package-name-1.0.0.tgz
|
||||||
|
const versionMatch = filename.match(/-([\d.]+(?:-[a-z0-9.]+)?)\.tgz$/);
|
||||||
|
if (!versionMatch) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EBADREQUEST', 'Invalid tarball filename'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = versionMatch[1];
|
||||||
|
const tarball = await this.storage.getNpmTarball(packageName, version);
|
||||||
|
|
||||||
|
if (!tarball) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('E404', 'Tarball not found'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': tarball.length.toString(),
|
||||||
|
},
|
||||||
|
body: tarball,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSearch(query: Record<string, string>): Promise<IResponse> {
|
||||||
|
const text = query.text || '';
|
||||||
|
const size = parseInt(query.size || '20', 10);
|
||||||
|
const from = parseInt(query.from || '0', 10);
|
||||||
|
|
||||||
|
// Simple search implementation (in production, use proper search index)
|
||||||
|
const results: ISearchResult[] = [];
|
||||||
|
|
||||||
|
// For now, return empty results
|
||||||
|
// In production, implement full-text search across packuments
|
||||||
|
|
||||||
|
const response: ISearchResponse = {
|
||||||
|
objects: results,
|
||||||
|
total: results.length,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUserAuth(
|
||||||
|
method: string,
|
||||||
|
username: string,
|
||||||
|
body: IUserAuthRequest,
|
||||||
|
token: IAuthToken | null
|
||||||
|
): Promise<IResponse> {
|
||||||
|
if (method !== 'PUT') {
|
||||||
|
return {
|
||||||
|
status: 405,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EBADREQUEST', 'Method not allowed'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || !body.name || !body.password) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EBADREQUEST', 'Invalid request'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate user
|
||||||
|
const userId = await this.authManager.authenticate({
|
||||||
|
username: body.name,
|
||||||
|
password: body.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EUNAUTHORIZED', 'Invalid credentials'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create NPM token
|
||||||
|
const npmToken = await this.authManager.createNpmToken(userId, false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: {
|
||||||
|
ok: true,
|
||||||
|
id: `org.couchdb.user:${username}`,
|
||||||
|
rev: '1-' + Date.now(),
|
||||||
|
token: npmToken,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTokens(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body: any,
|
||||||
|
token: IAuthToken | null
|
||||||
|
): Promise<IResponse> {
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EUNAUTHORIZED', 'Unauthorized'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// List tokens: GET /-/npm/v1/tokens
|
||||||
|
if (path === '/-/npm/v1/tokens' && method === 'GET') {
|
||||||
|
return this.listTokens(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token: POST /-/npm/v1/tokens
|
||||||
|
if (path === '/-/npm/v1/tokens' && method === 'POST') {
|
||||||
|
return this.createToken(body, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete token: DELETE /-/npm/v1/tokens/token/{key}
|
||||||
|
const deleteMatch = path.match(/^\/-\/npm\/v1\/tokens\/token\/(.+)$/);
|
||||||
|
if (deleteMatch && method === 'DELETE') {
|
||||||
|
return this.deleteToken(deleteMatch[1], token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('E404', 'Not found'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listTokens(token: IAuthToken): Promise<IResponse> {
|
||||||
|
const tokens = await this.authManager.listUserTokens(token.userId);
|
||||||
|
|
||||||
|
const response: ITokenListResponse = {
|
||||||
|
objects: tokens.map(t => ({
|
||||||
|
token: '********',
|
||||||
|
key: t.key,
|
||||||
|
readonly: t.readonly,
|
||||||
|
created: t.created,
|
||||||
|
updated: t.created,
|
||||||
|
})),
|
||||||
|
total: tokens.length,
|
||||||
|
urls: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createToken(body: ITokenCreateRequest, token: IAuthToken): Promise<IResponse> {
|
||||||
|
if (!body || !body.password) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EBADREQUEST', 'Password required'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password (simplified - in production, verify against stored password)
|
||||||
|
const readonly = body.readonly || false;
|
||||||
|
const newToken = await this.authManager.createNpmToken(token.userId, readonly);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: {
|
||||||
|
token: newToken,
|
||||||
|
key: 'sha512-' + newToken.substring(0, 16) + '...',
|
||||||
|
cidr_whitelist: body.cidr_whitelist || [],
|
||||||
|
readonly,
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteToken(key: string, token: IAuthToken): Promise<IResponse> {
|
||||||
|
// In production, lookup token by key hash and delete
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: { ok: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDistTags(
|
||||||
|
method: string,
|
||||||
|
packageName: string,
|
||||||
|
tag: string | undefined,
|
||||||
|
body: any,
|
||||||
|
token: IAuthToken | null
|
||||||
|
): Promise<IResponse> {
|
||||||
|
const packument = await this.storage.getNpmPackument(packageName);
|
||||||
|
if (!packument) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('E404', 'Package not found'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /-/package/{package}/dist-tags
|
||||||
|
if (method === 'GET' && !tag) {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: packument['dist-tags'] || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /-/package/{package}/dist-tags/{tag}
|
||||||
|
if (method === 'PUT' && tag) {
|
||||||
|
if (!await this.checkPermission(token, packageName, 'write')) {
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EUNAUTHORIZED', 'Unauthorized'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body !== 'string') {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EBADREQUEST', 'Version string required'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
packument['dist-tags'] = packument['dist-tags'] || {};
|
||||||
|
packument['dist-tags'][tag] = body;
|
||||||
|
await this.storage.putNpmPackument(packageName, packument);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: { ok: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /-/package/{package}/dist-tags/{tag}
|
||||||
|
if (method === 'DELETE' && tag) {
|
||||||
|
if (!await this.checkPermission(token, packageName, 'write')) {
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EUNAUTHORIZED', 'Unauthorized'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'latest') {
|
||||||
|
return {
|
||||||
|
status: 403,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EFORBIDDEN', 'Cannot delete latest tag'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packument['dist-tags'] && packument['dist-tags'][tag]) {
|
||||||
|
delete packument['dist-tags'][tag];
|
||||||
|
await this.storage.putNpmPackument(packageName, packument);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: { ok: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 405,
|
||||||
|
headers: {},
|
||||||
|
body: this.createError('EBADREQUEST', 'Method not allowed'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// HELPER METHODS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
private createError(code: string, message: string): INpmError {
|
||||||
|
return {
|
||||||
|
error: code,
|
||||||
|
reason: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ts/npm/index.ts
Normal file
6
ts/npm/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* NPM Registry module exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { NpmRegistry } from './classes.npmregistry.js';
|
||||||
|
export * from './interfaces.npm.js';
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { BaseRegistry } from '../core/classes.baseregistry.js';
|
import { BaseRegistry } from '../core/classes.baseregistry.js';
|
||||||
import { RegistryStorage } from '../core/classes.registrystorage.js';
|
import { RegistryStorage } from '../core/classes.registrystorage.js';
|
||||||
import { AuthManager } from '../core/classes.authmanager.js';
|
import { AuthManager } from '../core/classes.authmanager.js';
|
||||||
import { IRequestContext, IResponse, IAuthToken, IRegistryError } from '../core/interfaces.core.js';
|
import type { IRequestContext, IResponse, IAuthToken, IRegistryError } from '../core/interfaces.core.js';
|
||||||
import {
|
import type {
|
||||||
IUploadSession,
|
IUploadSession,
|
||||||
IOciManifest,
|
IOciManifest,
|
||||||
IOciImageIndex,
|
IOciImageIndex,
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
import * as plugins from './plugins.js';
|
|
||||||
import { IRegistryConfig, IOciManifest, IOciImageIndex, ITagList } from './interfaces.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage layer for OCI registry using SmartBucket
|
|
||||||
*/
|
|
||||||
export class RegistryStorage {
|
|
||||||
private smartBucket: plugins.smartbucket.SmartBucket;
|
|
||||||
private bucket: plugins.smartbucket.Bucket;
|
|
||||||
private bucketName: string;
|
|
||||||
|
|
||||||
constructor(private config: IRegistryConfig['storage']) {
|
|
||||||
this.bucketName = config.bucketName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the storage backend
|
|
||||||
*/
|
|
||||||
public async init() {
|
|
||||||
this.smartBucket = new plugins.smartbucket.SmartBucket({
|
|
||||||
accessKey: this.config.accessKey,
|
|
||||||
accessSecret: this.config.accessSecret,
|
|
||||||
endpoint: this.config.endpoint,
|
|
||||||
port: this.config.port || 443,
|
|
||||||
useSsl: this.config.useSsl !== false,
|
|
||||||
region: this.config.region || 'us-east-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure bucket exists
|
|
||||||
await this.smartBucket.createBucket(this.bucketName).catch(() => {
|
|
||||||
// Bucket may already exist, that's fine
|
|
||||||
});
|
|
||||||
|
|
||||||
this.bucket = await this.smartBucket.getBucketByName(this.bucketName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a blob
|
|
||||||
* @param digest - Content digest (e.g., "sha256:abc123...")
|
|
||||||
* @param data - Blob data
|
|
||||||
*/
|
|
||||||
public async putBlob(digest: string, data: Buffer): Promise<void> {
|
|
||||||
const path = this.getBlobPath(digest);
|
|
||||||
await this.bucket.fastPut({
|
|
||||||
path,
|
|
||||||
contents: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a blob
|
|
||||||
* @param digest - Content digest
|
|
||||||
* @returns Blob data or null if not found
|
|
||||||
*/
|
|
||||||
public async getBlob(digest: string): Promise<Buffer | null> {
|
|
||||||
const path = this.getBlobPath(digest);
|
|
||||||
try {
|
|
||||||
return await this.bucket.fastGet({ path });
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if blob exists
|
|
||||||
* @param digest - Content digest
|
|
||||||
* @returns true if exists
|
|
||||||
*/
|
|
||||||
public async blobExists(digest: string): Promise<boolean> {
|
|
||||||
const path = this.getBlobPath(digest);
|
|
||||||
return await this.bucket.fastExists({ path });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a blob
|
|
||||||
* @param digest - Content digest
|
|
||||||
*/
|
|
||||||
public async deleteBlob(digest: string): Promise<void> {
|
|
||||||
const path = this.getBlobPath(digest);
|
|
||||||
await this.bucket.fastRemove({ path });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a manifest
|
|
||||||
* @param repository - Repository name (e.g., "library/nginx")
|
|
||||||
* @param reference - Tag or digest
|
|
||||||
* @param manifest - Manifest content
|
|
||||||
* @param contentType - Manifest media type
|
|
||||||
*/
|
|
||||||
public async putManifest(
|
|
||||||
repository: string,
|
|
||||||
reference: string,
|
|
||||||
manifest: IOciManifest | IOciImageIndex,
|
|
||||||
contentType: string
|
|
||||||
): Promise<string> {
|
|
||||||
const manifestJson = JSON.stringify(manifest);
|
|
||||||
const manifestBuffer = Buffer.from(manifestJson, 'utf-8');
|
|
||||||
|
|
||||||
// Calculate digest
|
|
||||||
const digest = await this.calculateDigest(manifestBuffer);
|
|
||||||
|
|
||||||
// Store by digest
|
|
||||||
const digestPath = this.getManifestPath(repository, digest);
|
|
||||||
await this.bucket.fastPut({
|
|
||||||
path: digestPath,
|
|
||||||
contents: manifestBuffer,
|
|
||||||
meta: { 'Content-Type': contentType },
|
|
||||||
});
|
|
||||||
|
|
||||||
// If reference is a tag (not a digest), create/update tag mapping
|
|
||||||
if (!reference.startsWith('sha256:')) {
|
|
||||||
await this.putTag(repository, reference, digest);
|
|
||||||
}
|
|
||||||
|
|
||||||
return digest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a manifest
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param reference - Tag or digest
|
|
||||||
* @returns Manifest data and content type, or null if not found
|
|
||||||
*/
|
|
||||||
public async getManifest(
|
|
||||||
repository: string,
|
|
||||||
reference: string
|
|
||||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
|
||||||
let digest = reference;
|
|
||||||
|
|
||||||
// If reference is a tag, resolve to digest
|
|
||||||
if (!reference.startsWith('sha256:')) {
|
|
||||||
const resolvedDigest = await this.getTagDigest(repository, reference);
|
|
||||||
if (!resolvedDigest) return null;
|
|
||||||
digest = resolvedDigest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = this.getManifestPath(repository, digest);
|
|
||||||
try {
|
|
||||||
const data = await this.bucket.fastGet({ path });
|
|
||||||
// TODO: Retrieve content type from metadata if SmartBucket supports it
|
|
||||||
const contentType = 'application/vnd.oci.image.manifest.v1+json';
|
|
||||||
return { data, contentType };
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if manifest exists
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param reference - Tag or digest
|
|
||||||
* @returns true if exists
|
|
||||||
*/
|
|
||||||
public async manifestExists(repository: string, reference: string): Promise<boolean> {
|
|
||||||
let digest = reference;
|
|
||||||
|
|
||||||
// If reference is a tag, resolve to digest
|
|
||||||
if (!reference.startsWith('sha256:')) {
|
|
||||||
const resolvedDigest = await this.getTagDigest(repository, reference);
|
|
||||||
if (!resolvedDigest) return false;
|
|
||||||
digest = resolvedDigest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = this.getManifestPath(repository, digest);
|
|
||||||
return await this.bucket.fastExists({ path });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a manifest
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param digest - Manifest digest (must be digest, not tag)
|
|
||||||
*/
|
|
||||||
public async deleteManifest(repository: string, digest: string): Promise<void> {
|
|
||||||
const path = this.getManifestPath(repository, digest);
|
|
||||||
await this.bucket.fastRemove({ path });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store tag mapping
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param tag - Tag name
|
|
||||||
* @param digest - Manifest digest
|
|
||||||
*/
|
|
||||||
public async putTag(repository: string, tag: string, digest: string): Promise<void> {
|
|
||||||
const tags = await this.getTags(repository);
|
|
||||||
tags[tag] = digest;
|
|
||||||
|
|
||||||
const path = this.getTagsPath(repository);
|
|
||||||
await this.bucket.fastPut({
|
|
||||||
path,
|
|
||||||
contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get digest for a tag
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param tag - Tag name
|
|
||||||
* @returns Digest or null if tag doesn't exist
|
|
||||||
*/
|
|
||||||
public async getTagDigest(repository: string, tag: string): Promise<string | null> {
|
|
||||||
const tags = await this.getTags(repository);
|
|
||||||
return tags[tag] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all tags for a repository
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @returns Tag list
|
|
||||||
*/
|
|
||||||
public async listTags(repository: string): Promise<string[]> {
|
|
||||||
const tags = await this.getTags(repository);
|
|
||||||
return Object.keys(tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a tag
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param tag - Tag name
|
|
||||||
*/
|
|
||||||
public async deleteTag(repository: string, tag: string): Promise<void> {
|
|
||||||
const tags = await this.getTags(repository);
|
|
||||||
delete tags[tag];
|
|
||||||
|
|
||||||
const path = this.getTagsPath(repository);
|
|
||||||
await this.bucket.fastPut({
|
|
||||||
path,
|
|
||||||
contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all manifests that reference a specific digest (referrers API)
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param digest - Subject digest
|
|
||||||
* @returns Array of manifest digests
|
|
||||||
*/
|
|
||||||
public async getReferrers(repository: string, digest: string): Promise<string[]> {
|
|
||||||
// This is a simplified implementation
|
|
||||||
// In production, you'd want to maintain an index
|
|
||||||
const referrersPath = this.getReferrersPath(repository, digest);
|
|
||||||
try {
|
|
||||||
const data = await this.bucket.fastGet({ path: referrersPath });
|
|
||||||
const referrers = JSON.parse(data.toString('utf-8'));
|
|
||||||
return referrers;
|
|
||||||
} catch (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a referrer relationship
|
|
||||||
* @param repository - Repository name
|
|
||||||
* @param subjectDigest - Digest being referenced
|
|
||||||
* @param referrerDigest - Digest of the referrer
|
|
||||||
*/
|
|
||||||
public async addReferrer(
|
|
||||||
repository: string,
|
|
||||||
subjectDigest: string,
|
|
||||||
referrerDigest: string
|
|
||||||
): Promise<void> {
|
|
||||||
const referrers = await this.getReferrers(repository, subjectDigest);
|
|
||||||
if (!referrers.includes(referrerDigest)) {
|
|
||||||
referrers.push(referrerDigest);
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = this.getReferrersPath(repository, subjectDigest);
|
|
||||||
await this.bucket.fastPut({
|
|
||||||
path,
|
|
||||||
contents: Buffer.from(JSON.stringify(referrers, null, 2), 'utf-8'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper methods
|
|
||||||
|
|
||||||
private getBlobPath(digest: string): string {
|
|
||||||
// Remove algorithm prefix for path (sha256:abc -> abc)
|
|
||||||
const hash = digest.split(':')[1];
|
|
||||||
return `blobs/sha256/${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getManifestPath(repository: string, digest: string): string {
|
|
||||||
const hash = digest.split(':')[1];
|
|
||||||
return `manifests/${repository}/${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTagsPath(repository: string): string {
|
|
||||||
return `tags/${repository}/tags.json`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getReferrersPath(repository: string, digest: string): string {
|
|
||||||
const hash = digest.split(':')[1];
|
|
||||||
return `referrers/${repository}/${hash}.json`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getTags(repository: string): Promise<{ [tag: string]: string }> {
|
|
||||||
const path = this.getTagsPath(repository);
|
|
||||||
try {
|
|
||||||
const data = await this.bucket.fastGet({ path });
|
|
||||||
return JSON.parse(data.toString('utf-8'));
|
|
||||||
} catch (error) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async calculateDigest(data: Buffer): Promise<string> {
|
|
||||||
const crypto = await import('crypto');
|
|
||||||
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
|
||||||
return `sha256:${hash}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,4 +3,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { OciRegistry } from './classes.ociregistry.js';
|
export { OciRegistry } from './classes.ociregistry.js';
|
||||||
export * from './interfaces.oci.ts';
|
export * from './interfaces.oci.js';
|
||||||
|
|||||||
@@ -1,92 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Interfaces and types for OCI Distribution Specification compliant registry
|
* OCI Distribution Specification specific interfaces
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* Credentials for authentication
|
|
||||||
*/
|
|
||||||
export interface IRegistryCredentials {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Actions that can be performed on a repository
|
|
||||||
*/
|
|
||||||
export type TRegistryAction = 'pull' | 'push' | 'delete' | '*';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT token structure for OCI registry authentication
|
|
||||||
*/
|
|
||||||
export interface IRegistryToken {
|
|
||||||
/** Issuer */
|
|
||||||
iss: string;
|
|
||||||
/** Subject (user identifier) */
|
|
||||||
sub: string;
|
|
||||||
/** Audience (service name) */
|
|
||||||
aud: string;
|
|
||||||
/** Expiration timestamp */
|
|
||||||
exp: number;
|
|
||||||
/** Not before timestamp */
|
|
||||||
nbf: number;
|
|
||||||
/** Issued at timestamp */
|
|
||||||
iat: number;
|
|
||||||
/** JWT ID */
|
|
||||||
jti?: string;
|
|
||||||
/** Access permissions */
|
|
||||||
access: Array<{
|
|
||||||
type: 'repository' | 'registry';
|
|
||||||
name: string;
|
|
||||||
actions: TRegistryAction[];
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback function for user login - returns JWT token
|
|
||||||
* @param credentials - User credentials
|
|
||||||
* @returns JWT token string
|
|
||||||
*/
|
|
||||||
export type TLoginCallback = (
|
|
||||||
credentials: IRegistryCredentials
|
|
||||||
) => Promise<string>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback function for authorization check
|
|
||||||
* @param token - JWT token string
|
|
||||||
* @param repository - Repository name (e.g., "library/nginx")
|
|
||||||
* @param action - Action to perform
|
|
||||||
* @returns true if authorized, false otherwise
|
|
||||||
*/
|
|
||||||
export type TAuthCallback = (
|
|
||||||
token: string,
|
|
||||||
repository: string,
|
|
||||||
action: TRegistryAction
|
|
||||||
) => Promise<boolean>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for the registry
|
|
||||||
*/
|
|
||||||
export interface IRegistryConfig {
|
|
||||||
/** Storage bucket configuration */
|
|
||||||
storage: {
|
|
||||||
accessKey: string;
|
|
||||||
accessSecret: string;
|
|
||||||
endpoint: string;
|
|
||||||
port?: number;
|
|
||||||
useSsl?: boolean;
|
|
||||||
region?: string;
|
|
||||||
bucketName: string;
|
|
||||||
};
|
|
||||||
/** Service name for token authentication */
|
|
||||||
serviceName: string;
|
|
||||||
/** Token realm (authorization server URL) */
|
|
||||||
tokenRealm: string;
|
|
||||||
/** Login callback */
|
|
||||||
loginCallback: TLoginCallback;
|
|
||||||
/** Authorization callback */
|
|
||||||
authCallback: TAuthCallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OCI manifest structure
|
* OCI manifest structure
|
||||||
*/
|
*/
|
||||||
@@ -175,17 +90,6 @@ export interface IReferrersResponse {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Registry error response
|
|
||||||
*/
|
|
||||||
export interface IRegistryError {
|
|
||||||
errors: Array<{
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
detail?: any;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pagination options for listing
|
* Pagination options for listing
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user