@push.rocks/smartregistry 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/classes.smartregistry.d.ts +45 -0
- package/dist_ts/classes.smartregistry.js +113 -0
- package/dist_ts/core/classes.authmanager.d.ts +108 -0
- package/dist_ts/core/classes.authmanager.js +315 -0
- package/dist_ts/core/classes.baseregistry.d.ts +28 -0
- package/dist_ts/core/classes.baseregistry.js +6 -0
- package/dist_ts/core/classes.registrystorage.d.ts +109 -0
- package/dist_ts/core/classes.registrystorage.js +226 -0
- package/dist_ts/core/index.d.ts +7 -0
- package/dist_ts/core/index.js +10 -0
- package/dist_ts/core/interfaces.core.d.ts +142 -0
- package/dist_ts/core/interfaces.core.js +5 -0
- package/dist_ts/index.d.ts +8 -0
- package/dist_ts/index.js +13 -0
- package/dist_ts/npm/classes.npmregistry.d.ts +36 -0
- package/dist_ts/npm/classes.npmregistry.js +717 -0
- package/dist_ts/npm/index.d.ts +5 -0
- package/dist_ts/npm/index.js +6 -0
- package/dist_ts/npm/interfaces.npm.d.ts +245 -0
- package/dist_ts/npm/interfaces.npm.js +6 -0
- package/dist_ts/oci/classes.ociregistry.d.ts +43 -0
- package/dist_ts/oci/classes.ociregistry.js +565 -0
- package/dist_ts/oci/index.d.ts +5 -0
- package/dist_ts/oci/index.js +6 -0
- package/dist_ts/oci/interfaces.oci.d.ts +103 -0
- package/dist_ts/oci/interfaces.oci.js +5 -0
- package/dist_ts/paths.d.ts +1 -0
- package/dist_ts/paths.js +3 -0
- package/dist_ts/plugins.d.ts +6 -0
- package/dist_ts/plugins.js +9 -0
- package/npmextra.json +18 -0
- package/package.json +49 -0
- package/readme.hints.md +3 -0
- package/readme.md +486 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/classes.smartregistry.ts +129 -0
- package/ts/core/classes.authmanager.ts +388 -0
- package/ts/core/classes.baseregistry.ts +36 -0
- package/ts/core/classes.registrystorage.ts +270 -0
- package/ts/core/index.ts +11 -0
- package/ts/core/interfaces.core.ts +159 -0
- package/ts/index.ts +16 -0
- package/ts/npm/classes.npmregistry.ts +890 -0
- package/ts/npm/index.ts +6 -0
- package/ts/npm/interfaces.npm.ts +263 -0
- package/ts/oci/classes.ociregistry.ts +734 -0
- package/ts/oci/index.ts +6 -0
- package/ts/oci/interfaces.oci.ts +101 -0
- package/ts/paths.ts +5 -0
- package/ts/plugins.ts +11 -0
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
import { BaseRegistry } from '../core/classes.baseregistry.js';
|
|
2
|
+
import { RegistryStorage } from '../core/classes.registrystorage.js';
|
|
3
|
+
import { AuthManager } from '../core/classes.authmanager.js';
|
|
4
|
+
import type { IRequestContext, IResponse, IAuthToken, IRegistryError } from '../core/interfaces.core.js';
|
|
5
|
+
import type {
|
|
6
|
+
IUploadSession,
|
|
7
|
+
IOciManifest,
|
|
8
|
+
IOciImageIndex,
|
|
9
|
+
ITagList,
|
|
10
|
+
IReferrersResponse,
|
|
11
|
+
IPaginationOptions,
|
|
12
|
+
} from './interfaces.oci.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* OCI Distribution Specification v1.1 compliant registry
|
|
16
|
+
*/
|
|
17
|
+
export class OciRegistry extends BaseRegistry {
|
|
18
|
+
private storage: RegistryStorage;
|
|
19
|
+
private authManager: AuthManager;
|
|
20
|
+
private uploadSessions: Map<string, IUploadSession> = new Map();
|
|
21
|
+
private basePath: string = '/oci';
|
|
22
|
+
private cleanupInterval?: NodeJS.Timeout;
|
|
23
|
+
|
|
24
|
+
constructor(storage: RegistryStorage, authManager: AuthManager, basePath: string = '/oci') {
|
|
25
|
+
super();
|
|
26
|
+
this.storage = storage;
|
|
27
|
+
this.authManager = authManager;
|
|
28
|
+
this.basePath = basePath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public async init(): Promise<void> {
|
|
32
|
+
// Start cleanup of stale upload sessions
|
|
33
|
+
this.startUploadSessionCleanup();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public getBasePath(): string {
|
|
37
|
+
return this.basePath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public async handleRequest(context: IRequestContext): Promise<IResponse> {
|
|
41
|
+
// Remove base path from URL
|
|
42
|
+
const path = context.path.replace(this.basePath, '');
|
|
43
|
+
|
|
44
|
+
// Extract token from Authorization header
|
|
45
|
+
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
|
|
46
|
+
const tokenString = authHeader?.replace(/^Bearer\s+/i, '');
|
|
47
|
+
const token = tokenString ? await this.authManager.validateToken(tokenString, 'oci') : null;
|
|
48
|
+
|
|
49
|
+
// Route to appropriate handler
|
|
50
|
+
if (path === '/v2/' || path === '/v2') {
|
|
51
|
+
return this.handleVersionCheck();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Manifest operations: /v2/{name}/manifests/{reference}
|
|
55
|
+
const manifestMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
|
|
56
|
+
if (manifestMatch) {
|
|
57
|
+
const [, name, reference] = manifestMatch;
|
|
58
|
+
return this.handleManifestRequest(context.method, name, reference, token, context.body, context.headers);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Blob operations: /v2/{name}/blobs/{digest}
|
|
62
|
+
const blobMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
|
|
63
|
+
if (blobMatch) {
|
|
64
|
+
const [, name, digest] = blobMatch;
|
|
65
|
+
return this.handleBlobRequest(context.method, name, digest, token, context.headers);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Blob upload operations: /v2/{name}/blobs/uploads/
|
|
69
|
+
const uploadInitMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
|
|
70
|
+
if (uploadInitMatch && context.method === 'POST') {
|
|
71
|
+
const [, name] = uploadInitMatch;
|
|
72
|
+
return this.handleUploadInit(name, token, context.query, context.body);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Blob upload operations: /v2/{name}/blobs/uploads/{uuid}
|
|
76
|
+
const uploadMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
|
|
77
|
+
if (uploadMatch) {
|
|
78
|
+
const [, name, uploadId] = uploadMatch;
|
|
79
|
+
return this.handleUploadSession(context.method, uploadId, token, context);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Tags list: /v2/{name}/tags/list
|
|
83
|
+
const tagsMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
|
|
84
|
+
if (tagsMatch) {
|
|
85
|
+
const [, name] = tagsMatch;
|
|
86
|
+
return this.handleTagsList(name, token, context.query);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Referrers: /v2/{name}/referrers/{digest}
|
|
90
|
+
const referrersMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
|
|
91
|
+
if (referrersMatch) {
|
|
92
|
+
const [, name, digest] = referrersMatch;
|
|
93
|
+
return this.handleReferrers(name, digest, token, context.query);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
status: 404,
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
body: this.createError('NOT_FOUND', 'Endpoint not found'),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
protected async checkPermission(
|
|
104
|
+
token: IAuthToken | null,
|
|
105
|
+
resource: string,
|
|
106
|
+
action: string
|
|
107
|
+
): Promise<boolean> {
|
|
108
|
+
if (!token) return false;
|
|
109
|
+
return this.authManager.authorize(token, `oci:repository:${resource}`, action);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ========================================================================
|
|
113
|
+
// REQUEST HANDLERS
|
|
114
|
+
// ========================================================================
|
|
115
|
+
|
|
116
|
+
private handleVersionCheck(): IResponse {
|
|
117
|
+
return {
|
|
118
|
+
status: 200,
|
|
119
|
+
headers: {
|
|
120
|
+
'Content-Type': 'application/json',
|
|
121
|
+
'Docker-Distribution-API-Version': 'registry/2.0',
|
|
122
|
+
},
|
|
123
|
+
body: {},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async handleManifestRequest(
|
|
128
|
+
method: string,
|
|
129
|
+
repository: string,
|
|
130
|
+
reference: string,
|
|
131
|
+
token: IAuthToken | null,
|
|
132
|
+
body?: Buffer | any,
|
|
133
|
+
headers?: Record<string, string>
|
|
134
|
+
): Promise<IResponse> {
|
|
135
|
+
switch (method) {
|
|
136
|
+
case 'GET':
|
|
137
|
+
return this.getManifest(repository, reference, token, headers);
|
|
138
|
+
case 'HEAD':
|
|
139
|
+
return this.headManifest(repository, reference, token);
|
|
140
|
+
case 'PUT':
|
|
141
|
+
return this.putManifest(repository, reference, token, body, headers);
|
|
142
|
+
case 'DELETE':
|
|
143
|
+
return this.deleteManifest(repository, reference, token);
|
|
144
|
+
default:
|
|
145
|
+
return {
|
|
146
|
+
status: 405,
|
|
147
|
+
headers: {},
|
|
148
|
+
body: this.createError('UNSUPPORTED', 'Method not allowed'),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async handleBlobRequest(
|
|
154
|
+
method: string,
|
|
155
|
+
repository: string,
|
|
156
|
+
digest: string,
|
|
157
|
+
token: IAuthToken | null,
|
|
158
|
+
headers: Record<string, string>
|
|
159
|
+
): Promise<IResponse> {
|
|
160
|
+
switch (method) {
|
|
161
|
+
case 'GET':
|
|
162
|
+
return this.getBlob(repository, digest, token, headers['range'] || headers['Range']);
|
|
163
|
+
case 'HEAD':
|
|
164
|
+
return this.headBlob(repository, digest, token);
|
|
165
|
+
case 'DELETE':
|
|
166
|
+
return this.deleteBlob(repository, digest, token);
|
|
167
|
+
default:
|
|
168
|
+
return {
|
|
169
|
+
status: 405,
|
|
170
|
+
headers: {},
|
|
171
|
+
body: this.createError('UNSUPPORTED', 'Method not allowed'),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async handleUploadInit(
|
|
177
|
+
repository: string,
|
|
178
|
+
token: IAuthToken | null,
|
|
179
|
+
query: Record<string, string>,
|
|
180
|
+
body?: Buffer | any
|
|
181
|
+
): Promise<IResponse> {
|
|
182
|
+
if (!await this.checkPermission(token, repository, 'push')) {
|
|
183
|
+
return {
|
|
184
|
+
status: 401,
|
|
185
|
+
headers: {},
|
|
186
|
+
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check for monolithic upload (digest + body provided)
|
|
191
|
+
const digest = query.digest;
|
|
192
|
+
if (digest && body) {
|
|
193
|
+
// Monolithic upload: complete upload in single POST
|
|
194
|
+
const blobData = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body));
|
|
195
|
+
|
|
196
|
+
// Verify digest
|
|
197
|
+
const calculatedDigest = await this.calculateDigest(blobData);
|
|
198
|
+
if (calculatedDigest !== digest) {
|
|
199
|
+
return {
|
|
200
|
+
status: 400,
|
|
201
|
+
headers: {},
|
|
202
|
+
body: this.createError('DIGEST_INVALID', 'Provided digest does not match uploaded content'),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Store the blob
|
|
207
|
+
await this.storage.putOciBlob(digest, blobData);
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
status: 201,
|
|
211
|
+
headers: {
|
|
212
|
+
'Location': `${this.basePath}/v2/${repository}/blobs/${digest}`,
|
|
213
|
+
'Docker-Content-Digest': digest,
|
|
214
|
+
},
|
|
215
|
+
body: null,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Standard chunked upload: create session
|
|
220
|
+
const uploadId = this.generateUploadId();
|
|
221
|
+
const session: IUploadSession = {
|
|
222
|
+
uploadId,
|
|
223
|
+
repository,
|
|
224
|
+
chunks: [],
|
|
225
|
+
totalSize: 0,
|
|
226
|
+
createdAt: new Date(),
|
|
227
|
+
lastActivity: new Date(),
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
this.uploadSessions.set(uploadId, session);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
status: 202,
|
|
234
|
+
headers: {
|
|
235
|
+
'Location': `${this.basePath}/v2/${repository}/blobs/uploads/${uploadId}`,
|
|
236
|
+
'Docker-Upload-UUID': uploadId,
|
|
237
|
+
},
|
|
238
|
+
body: null,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async handleUploadSession(
|
|
243
|
+
method: string,
|
|
244
|
+
uploadId: string,
|
|
245
|
+
token: IAuthToken | null,
|
|
246
|
+
context: IRequestContext
|
|
247
|
+
): Promise<IResponse> {
|
|
248
|
+
const session = this.uploadSessions.get(uploadId);
|
|
249
|
+
if (!session) {
|
|
250
|
+
return {
|
|
251
|
+
status: 404,
|
|
252
|
+
headers: {},
|
|
253
|
+
body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!await this.checkPermission(token, session.repository, 'push')) {
|
|
258
|
+
return {
|
|
259
|
+
status: 401,
|
|
260
|
+
headers: {},
|
|
261
|
+
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
switch (method) {
|
|
266
|
+
case 'PATCH':
|
|
267
|
+
return this.uploadChunk(uploadId, context.body, context.headers['content-range']);
|
|
268
|
+
case 'PUT':
|
|
269
|
+
return this.completeUpload(uploadId, context.query['digest'], context.body);
|
|
270
|
+
case 'GET':
|
|
271
|
+
return this.getUploadStatus(uploadId);
|
|
272
|
+
default:
|
|
273
|
+
return {
|
|
274
|
+
status: 405,
|
|
275
|
+
headers: {},
|
|
276
|
+
body: this.createError('UNSUPPORTED', 'Method not allowed'),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Continuing with the actual OCI operations implementation...
|
|
282
|
+
// (The rest follows the same pattern as before, adapted to return IResponse objects)
|
|
283
|
+
|
|
284
|
+
private async getManifest(
|
|
285
|
+
repository: string,
|
|
286
|
+
reference: string,
|
|
287
|
+
token: IAuthToken | null,
|
|
288
|
+
headers?: Record<string, string>
|
|
289
|
+
): Promise<IResponse> {
|
|
290
|
+
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
291
|
+
return {
|
|
292
|
+
status: 401,
|
|
293
|
+
headers: {
|
|
294
|
+
'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:pull"`,
|
|
295
|
+
},
|
|
296
|
+
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Resolve tag to digest if needed
|
|
301
|
+
let digest = reference;
|
|
302
|
+
if (!reference.startsWith('sha256:')) {
|
|
303
|
+
const tags = await this.getTagsData(repository);
|
|
304
|
+
digest = tags[reference];
|
|
305
|
+
if (!digest) {
|
|
306
|
+
return {
|
|
307
|
+
status: 404,
|
|
308
|
+
headers: {},
|
|
309
|
+
body: this.createError('MANIFEST_UNKNOWN', 'Manifest not found'),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const manifestData = await this.storage.getOciManifest(repository, digest);
|
|
315
|
+
if (!manifestData) {
|
|
316
|
+
return {
|
|
317
|
+
status: 404,
|
|
318
|
+
headers: {},
|
|
319
|
+
body: this.createError('MANIFEST_UNKNOWN', 'Manifest not found'),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
status: 200,
|
|
325
|
+
headers: {
|
|
326
|
+
'Content-Type': 'application/vnd.oci.image.manifest.v1+json',
|
|
327
|
+
'Docker-Content-Digest': digest,
|
|
328
|
+
},
|
|
329
|
+
body: manifestData,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async headManifest(
|
|
334
|
+
repository: string,
|
|
335
|
+
reference: string,
|
|
336
|
+
token: IAuthToken | null
|
|
337
|
+
): Promise<IResponse> {
|
|
338
|
+
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
339
|
+
return {
|
|
340
|
+
status: 401,
|
|
341
|
+
headers: {},
|
|
342
|
+
body: null,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Similar logic as getManifest but return headers only
|
|
347
|
+
let digest = reference;
|
|
348
|
+
if (!reference.startsWith('sha256:')) {
|
|
349
|
+
const tags = await this.getTagsData(repository);
|
|
350
|
+
digest = tags[reference];
|
|
351
|
+
if (!digest) {
|
|
352
|
+
return { status: 404, headers: {}, body: null };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const exists = await this.storage.ociManifestExists(repository, digest);
|
|
357
|
+
if (!exists) {
|
|
358
|
+
return { status: 404, headers: {}, body: null };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const manifestData = await this.storage.getOciManifest(repository, digest);
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
status: 200,
|
|
365
|
+
headers: {
|
|
366
|
+
'Content-Type': 'application/vnd.oci.image.manifest.v1+json',
|
|
367
|
+
'Docker-Content-Digest': digest,
|
|
368
|
+
'Content-Length': manifestData ? manifestData.length.toString() : '0',
|
|
369
|
+
},
|
|
370
|
+
body: null,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private async putManifest(
|
|
375
|
+
repository: string,
|
|
376
|
+
reference: string,
|
|
377
|
+
token: IAuthToken | null,
|
|
378
|
+
body?: Buffer | any,
|
|
379
|
+
headers?: Record<string, string>
|
|
380
|
+
): Promise<IResponse> {
|
|
381
|
+
if (!await this.checkPermission(token, repository, 'push')) {
|
|
382
|
+
return {
|
|
383
|
+
status: 401,
|
|
384
|
+
headers: {
|
|
385
|
+
'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:push"`,
|
|
386
|
+
},
|
|
387
|
+
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!body) {
|
|
392
|
+
return {
|
|
393
|
+
status: 400,
|
|
394
|
+
headers: {},
|
|
395
|
+
body: this.createError('MANIFEST_INVALID', 'Manifest body is required'),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const manifestData = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body));
|
|
400
|
+
const contentType = headers?.['content-type'] || headers?.['Content-Type'] || 'application/vnd.oci.image.manifest.v1+json';
|
|
401
|
+
|
|
402
|
+
// Calculate manifest digest
|
|
403
|
+
const digest = await this.calculateDigest(manifestData);
|
|
404
|
+
|
|
405
|
+
// Store manifest by digest
|
|
406
|
+
await this.storage.putOciManifest(repository, digest, manifestData, contentType);
|
|
407
|
+
|
|
408
|
+
// If reference is a tag (not a digest), update tags mapping
|
|
409
|
+
if (!reference.startsWith('sha256:')) {
|
|
410
|
+
const tags = await this.getTagsData(repository);
|
|
411
|
+
tags[reference] = digest;
|
|
412
|
+
const tagsPath = `oci/tags/${repository}/tags.json`;
|
|
413
|
+
await this.storage.putObject(tagsPath, Buffer.from(JSON.stringify(tags), 'utf-8'));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
status: 201,
|
|
418
|
+
headers: {
|
|
419
|
+
'Location': `${this.basePath}/v2/${repository}/manifests/${digest}`,
|
|
420
|
+
'Docker-Content-Digest': digest,
|
|
421
|
+
},
|
|
422
|
+
body: null,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private async deleteManifest(
|
|
427
|
+
repository: string,
|
|
428
|
+
digest: string,
|
|
429
|
+
token: IAuthToken | null
|
|
430
|
+
): Promise<IResponse> {
|
|
431
|
+
if (!digest.startsWith('sha256:')) {
|
|
432
|
+
return {
|
|
433
|
+
status: 400,
|
|
434
|
+
headers: {},
|
|
435
|
+
body: this.createError('UNSUPPORTED', 'Must use digest for deletion'),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (!await this.checkPermission(token, repository, 'delete')) {
|
|
440
|
+
return {
|
|
441
|
+
status: 401,
|
|
442
|
+
headers: {},
|
|
443
|
+
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
await this.storage.deleteOciManifest(repository, digest);
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
status: 202,
|
|
451
|
+
headers: {},
|
|
452
|
+
body: null,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private async getBlob(
|
|
457
|
+
repository: string,
|
|
458
|
+
digest: string,
|
|
459
|
+
token: IAuthToken | null,
|
|
460
|
+
range?: string
|
|
461
|
+
): Promise<IResponse> {
|
|
462
|
+
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
463
|
+
return {
|
|
464
|
+
status: 401,
|
|
465
|
+
headers: {},
|
|
466
|
+
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const data = await this.storage.getOciBlob(digest);
|
|
471
|
+
if (!data) {
|
|
472
|
+
return {
|
|
473
|
+
status: 404,
|
|
474
|
+
headers: {},
|
|
475
|
+
body: this.createError('BLOB_UNKNOWN', 'Blob not found'),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
status: 200,
|
|
481
|
+
headers: {
|
|
482
|
+
'Content-Type': 'application/octet-stream',
|
|
483
|
+
'Docker-Content-Digest': digest,
|
|
484
|
+
},
|
|
485
|
+
body: data,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private async headBlob(
|
|
490
|
+
repository: string,
|
|
491
|
+
digest: string,
|
|
492
|
+
token: IAuthToken | null
|
|
493
|
+
): Promise<IResponse> {
|
|
494
|
+
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
495
|
+
return { status: 401, headers: {}, body: null };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const exists = await this.storage.ociBlobExists(digest);
|
|
499
|
+
if (!exists) {
|
|
500
|
+
return { status: 404, headers: {}, body: null };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const blob = await this.storage.getOciBlob(digest);
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
status: 200,
|
|
507
|
+
headers: {
|
|
508
|
+
'Content-Length': blob ? blob.length.toString() : '0',
|
|
509
|
+
'Docker-Content-Digest': digest,
|
|
510
|
+
},
|
|
511
|
+
body: null,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private async deleteBlob(
|
|
516
|
+
repository: string,
|
|
517
|
+
digest: string,
|
|
518
|
+
token: IAuthToken | null
|
|
519
|
+
): Promise<IResponse> {
|
|
520
|
+
if (!await this.checkPermission(token, repository, 'delete')) {
|
|
521
|
+
return {
|
|
522
|
+
status: 401,
|
|
523
|
+
headers: {},
|
|
524
|
+
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
await this.storage.deleteOciBlob(digest);
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
status: 202,
|
|
532
|
+
headers: {},
|
|
533
|
+
body: null,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private async uploadChunk(
|
|
538
|
+
uploadId: string,
|
|
539
|
+
data: Buffer,
|
|
540
|
+
contentRange: string
|
|
541
|
+
): Promise<IResponse> {
|
|
542
|
+
const session = this.uploadSessions.get(uploadId);
|
|
543
|
+
if (!session) {
|
|
544
|
+
return {
|
|
545
|
+
status: 404,
|
|
546
|
+
headers: {},
|
|
547
|
+
body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
session.chunks.push(data);
|
|
552
|
+
session.totalSize += data.length;
|
|
553
|
+
session.lastActivity = new Date();
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
status: 202,
|
|
557
|
+
headers: {
|
|
558
|
+
'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`,
|
|
559
|
+
'Range': `0-${session.totalSize - 1}`,
|
|
560
|
+
'Docker-Upload-UUID': uploadId,
|
|
561
|
+
},
|
|
562
|
+
body: null,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private async completeUpload(
|
|
567
|
+
uploadId: string,
|
|
568
|
+
digest: string,
|
|
569
|
+
finalData?: Buffer
|
|
570
|
+
): Promise<IResponse> {
|
|
571
|
+
const session = this.uploadSessions.get(uploadId);
|
|
572
|
+
if (!session) {
|
|
573
|
+
return {
|
|
574
|
+
status: 404,
|
|
575
|
+
headers: {},
|
|
576
|
+
body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const chunks = [...session.chunks];
|
|
581
|
+
if (finalData) chunks.push(finalData);
|
|
582
|
+
const blobData = Buffer.concat(chunks);
|
|
583
|
+
|
|
584
|
+
// Verify digest
|
|
585
|
+
const calculatedDigest = await this.calculateDigest(blobData);
|
|
586
|
+
if (calculatedDigest !== digest) {
|
|
587
|
+
return {
|
|
588
|
+
status: 400,
|
|
589
|
+
headers: {},
|
|
590
|
+
body: this.createError('DIGEST_INVALID', 'Digest mismatch'),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
await this.storage.putOciBlob(digest, blobData);
|
|
595
|
+
this.uploadSessions.delete(uploadId);
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
status: 201,
|
|
599
|
+
headers: {
|
|
600
|
+
'Location': `${this.basePath}/v2/${session.repository}/blobs/${digest}`,
|
|
601
|
+
'Docker-Content-Digest': digest,
|
|
602
|
+
},
|
|
603
|
+
body: null,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private async getUploadStatus(uploadId: string): Promise<IResponse> {
|
|
608
|
+
const session = this.uploadSessions.get(uploadId);
|
|
609
|
+
if (!session) {
|
|
610
|
+
return {
|
|
611
|
+
status: 404,
|
|
612
|
+
headers: {},
|
|
613
|
+
body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
status: 204,
|
|
619
|
+
headers: {
|
|
620
|
+
'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`,
|
|
621
|
+
'Range': session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0',
|
|
622
|
+
'Docker-Upload-UUID': uploadId,
|
|
623
|
+
},
|
|
624
|
+
body: null,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private async handleTagsList(
|
|
629
|
+
repository: string,
|
|
630
|
+
token: IAuthToken | null,
|
|
631
|
+
query: Record<string, string>
|
|
632
|
+
): Promise<IResponse> {
|
|
633
|
+
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
634
|
+
return {
|
|
635
|
+
status: 401,
|
|
636
|
+
headers: {},
|
|
637
|
+
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const tags = await this.getTagsData(repository);
|
|
642
|
+
const tagNames = Object.keys(tags);
|
|
643
|
+
|
|
644
|
+
const response: ITagList = {
|
|
645
|
+
name: repository,
|
|
646
|
+
tags: tagNames,
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
status: 200,
|
|
651
|
+
headers: { 'Content-Type': 'application/json' },
|
|
652
|
+
body: response,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private async handleReferrers(
|
|
657
|
+
repository: string,
|
|
658
|
+
digest: string,
|
|
659
|
+
token: IAuthToken | null,
|
|
660
|
+
query: Record<string, string>
|
|
661
|
+
): Promise<IResponse> {
|
|
662
|
+
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
663
|
+
return {
|
|
664
|
+
status: 401,
|
|
665
|
+
headers: {},
|
|
666
|
+
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const response: IReferrersResponse = {
|
|
671
|
+
schemaVersion: 2,
|
|
672
|
+
mediaType: 'application/vnd.oci.image.index.v1+json',
|
|
673
|
+
manifests: [],
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
status: 200,
|
|
678
|
+
headers: { 'Content-Type': 'application/json' },
|
|
679
|
+
body: response,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ========================================================================
|
|
684
|
+
// HELPER METHODS
|
|
685
|
+
// ========================================================================
|
|
686
|
+
|
|
687
|
+
private async getTagsData(repository: string): Promise<Record<string, string>> {
|
|
688
|
+
const path = `oci/tags/${repository}/tags.json`;
|
|
689
|
+
const data = await this.storage.getObject(path);
|
|
690
|
+
return data ? JSON.parse(data.toString('utf-8')) : {};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private async putTagsData(repository: string, tags: Record<string, string>): Promise<void> {
|
|
694
|
+
const path = `oci/tags/${repository}/tags.json`;
|
|
695
|
+
const data = Buffer.from(JSON.stringify(tags, null, 2), 'utf-8');
|
|
696
|
+
await this.storage.putObject(path, data);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private generateUploadId(): string {
|
|
700
|
+
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private async calculateDigest(data: Buffer): Promise<string> {
|
|
704
|
+
const crypto = await import('crypto');
|
|
705
|
+
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
|
706
|
+
return `sha256:${hash}`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private createError(code: string, message: string, detail?: any): IRegistryError {
|
|
710
|
+
return {
|
|
711
|
+
errors: [{ code, message, detail }],
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private startUploadSessionCleanup(): void {
|
|
716
|
+
this.cleanupInterval = setInterval(() => {
|
|
717
|
+
const now = new Date();
|
|
718
|
+
const maxAge = 60 * 60 * 1000; // 1 hour
|
|
719
|
+
|
|
720
|
+
for (const [uploadId, session] of this.uploadSessions.entries()) {
|
|
721
|
+
if (now.getTime() - session.lastActivity.getTime() > maxAge) {
|
|
722
|
+
this.uploadSessions.delete(uploadId);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}, 10 * 60 * 1000);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
public destroy(): void {
|
|
729
|
+
if (this.cleanupInterval) {
|
|
730
|
+
clearInterval(this.cleanupInterval);
|
|
731
|
+
this.cleanupInterval = undefined;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|