@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.
Files changed (52) hide show
  1. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  2. package/dist_ts/00_commitinfo_data.js +9 -0
  3. package/dist_ts/classes.smartregistry.d.ts +45 -0
  4. package/dist_ts/classes.smartregistry.js +113 -0
  5. package/dist_ts/core/classes.authmanager.d.ts +108 -0
  6. package/dist_ts/core/classes.authmanager.js +315 -0
  7. package/dist_ts/core/classes.baseregistry.d.ts +28 -0
  8. package/dist_ts/core/classes.baseregistry.js +6 -0
  9. package/dist_ts/core/classes.registrystorage.d.ts +109 -0
  10. package/dist_ts/core/classes.registrystorage.js +226 -0
  11. package/dist_ts/core/index.d.ts +7 -0
  12. package/dist_ts/core/index.js +10 -0
  13. package/dist_ts/core/interfaces.core.d.ts +142 -0
  14. package/dist_ts/core/interfaces.core.js +5 -0
  15. package/dist_ts/index.d.ts +8 -0
  16. package/dist_ts/index.js +13 -0
  17. package/dist_ts/npm/classes.npmregistry.d.ts +36 -0
  18. package/dist_ts/npm/classes.npmregistry.js +717 -0
  19. package/dist_ts/npm/index.d.ts +5 -0
  20. package/dist_ts/npm/index.js +6 -0
  21. package/dist_ts/npm/interfaces.npm.d.ts +245 -0
  22. package/dist_ts/npm/interfaces.npm.js +6 -0
  23. package/dist_ts/oci/classes.ociregistry.d.ts +43 -0
  24. package/dist_ts/oci/classes.ociregistry.js +565 -0
  25. package/dist_ts/oci/index.d.ts +5 -0
  26. package/dist_ts/oci/index.js +6 -0
  27. package/dist_ts/oci/interfaces.oci.d.ts +103 -0
  28. package/dist_ts/oci/interfaces.oci.js +5 -0
  29. package/dist_ts/paths.d.ts +1 -0
  30. package/dist_ts/paths.js +3 -0
  31. package/dist_ts/plugins.d.ts +6 -0
  32. package/dist_ts/plugins.js +9 -0
  33. package/npmextra.json +18 -0
  34. package/package.json +49 -0
  35. package/readme.hints.md +3 -0
  36. package/readme.md +486 -0
  37. package/ts/00_commitinfo_data.ts +8 -0
  38. package/ts/classes.smartregistry.ts +129 -0
  39. package/ts/core/classes.authmanager.ts +388 -0
  40. package/ts/core/classes.baseregistry.ts +36 -0
  41. package/ts/core/classes.registrystorage.ts +270 -0
  42. package/ts/core/index.ts +11 -0
  43. package/ts/core/interfaces.core.ts +159 -0
  44. package/ts/index.ts +16 -0
  45. package/ts/npm/classes.npmregistry.ts +890 -0
  46. package/ts/npm/index.ts +6 -0
  47. package/ts/npm/interfaces.npm.ts +263 -0
  48. package/ts/oci/classes.ociregistry.ts +734 -0
  49. package/ts/oci/index.ts +6 -0
  50. package/ts/oci/interfaces.oci.ts +101 -0
  51. package/ts/paths.ts +5 -0
  52. 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
+ }