@push.rocks/smartregistry 1.4.1 → 1.5.0

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.
@@ -0,0 +1,564 @@
1
+ import { Smartlog } from '@push.rocks/smartlog';
2
+ import { BaseRegistry } from '../core/classes.baseregistry.js';
3
+ import { RegistryStorage } from '../core/classes.registrystorage.js';
4
+ import { AuthManager } from '../core/classes.authmanager.js';
5
+ import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js';
6
+ import type {
7
+ IPypiPackageMetadata,
8
+ IPypiFile,
9
+ IPypiError,
10
+ IPypiUploadResponse,
11
+ } from './interfaces.pypi.js';
12
+ import * as helpers from './helpers.pypi.js';
13
+
14
+ /**
15
+ * PyPI registry implementation
16
+ * Implements PEP 503 (Simple API), PEP 691 (JSON API), and legacy upload API
17
+ */
18
+ export class PypiRegistry extends BaseRegistry {
19
+ private storage: RegistryStorage;
20
+ private authManager: AuthManager;
21
+ private basePath: string = '/pypi';
22
+ private registryUrl: string;
23
+ private logger: Smartlog;
24
+
25
+ constructor(
26
+ storage: RegistryStorage,
27
+ authManager: AuthManager,
28
+ basePath: string = '/pypi',
29
+ registryUrl: string = 'http://localhost:5000'
30
+ ) {
31
+ super();
32
+ this.storage = storage;
33
+ this.authManager = authManager;
34
+ this.basePath = basePath;
35
+ this.registryUrl = registryUrl;
36
+
37
+ // Initialize logger
38
+ this.logger = new Smartlog({
39
+ logContext: {
40
+ company: 'push.rocks',
41
+ companyunit: 'smartregistry',
42
+ containerName: 'pypi-registry',
43
+ environment: (process.env.NODE_ENV as any) || 'development',
44
+ runtime: 'node',
45
+ zone: 'pypi'
46
+ }
47
+ });
48
+ this.logger.enableConsole();
49
+ }
50
+
51
+ public async init(): Promise<void> {
52
+ // Initialize root Simple API index if not exists
53
+ const existingIndex = await this.storage.getPypiSimpleRootIndex();
54
+ if (!existingIndex) {
55
+ const html = helpers.generateSimpleRootHtml([]);
56
+ await this.storage.putPypiSimpleRootIndex(html);
57
+ this.logger.log('info', 'Initialized PyPI root index');
58
+ }
59
+ }
60
+
61
+ public getBasePath(): string {
62
+ return this.basePath;
63
+ }
64
+
65
+ public async handleRequest(context: IRequestContext): Promise<IResponse> {
66
+ let path = context.path.replace(this.basePath, '');
67
+
68
+ // Also handle /simple path prefix
69
+ if (path.startsWith('/simple')) {
70
+ path = path.replace('/simple', '');
71
+ return this.handleSimpleRequest(path, context);
72
+ }
73
+
74
+ // Extract token (Basic Auth or Bearer)
75
+ const token = await this.extractToken(context);
76
+
77
+ this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
78
+ method: context.method,
79
+ path,
80
+ hasAuth: !!token
81
+ });
82
+
83
+ // Root upload endpoint (POST /)
84
+ if ((path === '/' || path === '') && context.method === 'POST') {
85
+ return this.handleUpload(context, token);
86
+ }
87
+
88
+ // Package metadata JSON API: GET /pypi/{package}/json
89
+ const jsonMatch = path.match(/^\/pypi\/([^\/]+)\/json$/);
90
+ if (jsonMatch && context.method === 'GET') {
91
+ return this.handlePackageJson(jsonMatch[1]);
92
+ }
93
+
94
+ // Version-specific JSON API: GET /pypi/{package}/{version}/json
95
+ const versionJsonMatch = path.match(/^\/pypi\/([^\/]+)\/([^\/]+)\/json$/);
96
+ if (versionJsonMatch && context.method === 'GET') {
97
+ return this.handleVersionJson(versionJsonMatch[1], versionJsonMatch[2]);
98
+ }
99
+
100
+ // Package file download: GET /packages/{package}/{filename}
101
+ const downloadMatch = path.match(/^\/packages\/([^\/]+)\/(.+)$/);
102
+ if (downloadMatch && context.method === 'GET') {
103
+ return this.handleDownload(downloadMatch[1], downloadMatch[2]);
104
+ }
105
+
106
+ // Delete package: DELETE /packages/{package}
107
+ if (path.match(/^\/packages\/([^\/]+)$/) && context.method === 'DELETE') {
108
+ const packageName = path.match(/^\/packages\/([^\/]+)$/)?.[1];
109
+ return this.handleDeletePackage(packageName!, token);
110
+ }
111
+
112
+ // Delete version: DELETE /packages/{package}/{version}
113
+ const deleteVersionMatch = path.match(/^\/packages\/([^\/]+)\/([^\/]+)$/);
114
+ if (deleteVersionMatch && context.method === 'DELETE') {
115
+ return this.handleDeleteVersion(deleteVersionMatch[1], deleteVersionMatch[2], token);
116
+ }
117
+
118
+ return {
119
+ status: 404,
120
+ headers: { 'Content-Type': 'application/json' },
121
+ body: Buffer.from(JSON.stringify({ message: 'Not Found' })),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Check if token has permission for resource
127
+ */
128
+ protected async checkPermission(
129
+ token: IAuthToken | null,
130
+ resource: string,
131
+ action: string
132
+ ): Promise<boolean> {
133
+ if (!token) return false;
134
+ return this.authManager.authorize(token, `pypi:package:${resource}`, action);
135
+ }
136
+
137
+ /**
138
+ * Handle Simple API requests (PEP 503 HTML or PEP 691 JSON)
139
+ */
140
+ private async handleSimpleRequest(path: string, context: IRequestContext): Promise<IResponse> {
141
+ // Ensure path ends with / (PEP 503 requirement)
142
+ if (!path.endsWith('/') && !path.includes('.')) {
143
+ return {
144
+ status: 301,
145
+ headers: { 'Location': `${this.basePath}/simple${path}/` },
146
+ body: Buffer.from(''),
147
+ };
148
+ }
149
+
150
+ // Root index: /simple/
151
+ if (path === '/' || path === '') {
152
+ return this.handleSimpleRoot(context);
153
+ }
154
+
155
+ // Package index: /simple/{package}/
156
+ const packageMatch = path.match(/^\/([^\/]+)\/$/);
157
+ if (packageMatch) {
158
+ return this.handleSimplePackage(packageMatch[1], context);
159
+ }
160
+
161
+ return {
162
+ status: 404,
163
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
164
+ body: Buffer.from('<html><body><h1>404 Not Found</h1></body></html>'),
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Handle Simple API root index
170
+ * Returns HTML (PEP 503) or JSON (PEP 691) based on Accept header
171
+ */
172
+ private async handleSimpleRoot(context: IRequestContext): Promise<IResponse> {
173
+ const acceptHeader = context.headers['accept'] || context.headers['Accept'] || '';
174
+ const preferJson = acceptHeader.includes('application/vnd.pypi.simple') &&
175
+ acceptHeader.includes('json');
176
+
177
+ const packages = await this.storage.listPypiPackages();
178
+
179
+ if (preferJson) {
180
+ // PEP 691: JSON response
181
+ const response = helpers.generateJsonRootResponse(packages);
182
+ return {
183
+ status: 200,
184
+ headers: {
185
+ 'Content-Type': 'application/vnd.pypi.simple.v1+json',
186
+ 'Cache-Control': 'public, max-age=600'
187
+ },
188
+ body: Buffer.from(JSON.stringify(response)),
189
+ };
190
+ } else {
191
+ // PEP 503: HTML response
192
+ const html = helpers.generateSimpleRootHtml(packages);
193
+
194
+ // Update stored index
195
+ await this.storage.putPypiSimpleRootIndex(html);
196
+
197
+ return {
198
+ status: 200,
199
+ headers: {
200
+ 'Content-Type': 'text/html; charset=utf-8',
201
+ 'Cache-Control': 'public, max-age=600'
202
+ },
203
+ body: Buffer.from(html),
204
+ };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Handle Simple API package index
210
+ * Returns HTML (PEP 503) or JSON (PEP 691) based on Accept header
211
+ */
212
+ private async handleSimplePackage(packageName: string, context: IRequestContext): Promise<IResponse> {
213
+ const normalized = helpers.normalizePypiPackageName(packageName);
214
+
215
+ // Get package metadata
216
+ const metadata = await this.storage.getPypiPackageMetadata(normalized);
217
+ if (!metadata) {
218
+ return {
219
+ status: 404,
220
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
221
+ body: Buffer.from('<html><body><h1>404 Not Found</h1></body></html>'),
222
+ };
223
+ }
224
+
225
+ // Build file list from all versions
226
+ const files: IPypiFile[] = [];
227
+ for (const [version, versionMeta] of Object.entries(metadata.versions || {})) {
228
+ for (const file of (versionMeta as any).files || []) {
229
+ files.push({
230
+ filename: file.filename,
231
+ url: `${this.registryUrl}/pypi/packages/${normalized}/${file.filename}`,
232
+ hashes: file.hashes,
233
+ 'requires-python': file['requires-python'],
234
+ yanked: file.yanked || (versionMeta as any).yanked,
235
+ size: file.size,
236
+ 'upload-time': file['upload-time'],
237
+ });
238
+ }
239
+ }
240
+
241
+ const acceptHeader = context.headers['accept'] || context.headers['Accept'] || '';
242
+ const preferJson = acceptHeader.includes('application/vnd.pypi.simple') &&
243
+ acceptHeader.includes('json');
244
+
245
+ if (preferJson) {
246
+ // PEP 691: JSON response
247
+ const response = helpers.generateJsonPackageResponse(normalized, files);
248
+ return {
249
+ status: 200,
250
+ headers: {
251
+ 'Content-Type': 'application/vnd.pypi.simple.v1+json',
252
+ 'Cache-Control': 'public, max-age=300'
253
+ },
254
+ body: Buffer.from(JSON.stringify(response)),
255
+ };
256
+ } else {
257
+ // PEP 503: HTML response
258
+ const html = helpers.generateSimplePackageHtml(normalized, files, this.registryUrl);
259
+
260
+ // Update stored index
261
+ await this.storage.putPypiSimpleIndex(normalized, html);
262
+
263
+ return {
264
+ status: 200,
265
+ headers: {
266
+ 'Content-Type': 'text/html; charset=utf-8',
267
+ 'Cache-Control': 'public, max-age=300'
268
+ },
269
+ body: Buffer.from(html),
270
+ };
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Extract authentication token from request
276
+ */
277
+ private async extractToken(context: IRequestContext): Promise<IAuthToken | null> {
278
+ const authHeader = context.headers['authorization'] || context.headers['Authorization'];
279
+ if (!authHeader) return null;
280
+
281
+ // Handle Basic Auth (username:password or __token__:token)
282
+ if (authHeader.startsWith('Basic ')) {
283
+ const base64 = authHeader.substring(6);
284
+ const decoded = Buffer.from(base64, 'base64').toString('utf-8');
285
+ const [username, password] = decoded.split(':');
286
+
287
+ // PyPI token authentication: username = __token__
288
+ if (username === '__token__') {
289
+ return this.authManager.validateToken(password, 'pypi');
290
+ }
291
+
292
+ // Username/password authentication (would need user lookup)
293
+ // For now, not implemented
294
+ return null;
295
+ }
296
+
297
+ // Handle Bearer token
298
+ if (authHeader.startsWith('Bearer ')) {
299
+ const token = authHeader.substring(7);
300
+ return this.authManager.validateToken(token, 'pypi');
301
+ }
302
+
303
+ return null;
304
+ }
305
+
306
+ /**
307
+ * Handle package upload (multipart/form-data)
308
+ * POST / with :action=file_upload
309
+ */
310
+ private async handleUpload(context: IRequestContext, token: IAuthToken | null): Promise<IResponse> {
311
+ if (!token) {
312
+ return {
313
+ status: 401,
314
+ headers: {
315
+ 'Content-Type': 'application/json',
316
+ 'WWW-Authenticate': 'Basic realm="PyPI"'
317
+ },
318
+ body: Buffer.from(JSON.stringify({ message: 'Authentication required' })),
319
+ };
320
+ }
321
+
322
+ try {
323
+ // Parse multipart form data (context.body should be parsed by server)
324
+ const formData = context.body as any; // Assuming parsed multipart data
325
+
326
+ if (!formData || formData[':action'] !== 'file_upload') {
327
+ return this.errorResponse(400, 'Invalid upload request');
328
+ }
329
+
330
+ // Extract required fields
331
+ const packageName = formData.name;
332
+ const version = formData.version;
333
+ const filename = formData.content?.filename;
334
+ const fileData = formData.content?.data as Buffer;
335
+ const filetype = formData.filetype; // 'bdist_wheel' or 'sdist'
336
+ const pyversion = formData.pyversion;
337
+
338
+ if (!packageName || !version || !filename || !fileData) {
339
+ return this.errorResponse(400, 'Missing required fields');
340
+ }
341
+
342
+ // Validate package name
343
+ if (!helpers.isValidPackageName(packageName)) {
344
+ return this.errorResponse(400, 'Invalid package name');
345
+ }
346
+
347
+ const normalized = helpers.normalizePypiPackageName(packageName);
348
+
349
+ // Check permission
350
+ if (!(await this.checkPermission(token, normalized, 'write'))) {
351
+ return this.errorResponse(403, 'Insufficient permissions');
352
+ }
353
+
354
+ // Calculate hashes
355
+ const hashes: Record<string, string> = {};
356
+
357
+ if (formData.sha256_digest) {
358
+ hashes.sha256 = formData.sha256_digest;
359
+ } else {
360
+ hashes.sha256 = await helpers.calculateHash(fileData, 'sha256');
361
+ }
362
+
363
+ if (formData.md5_digest) {
364
+ // MD5 digest in PyPI is urlsafe base64, convert to hex
365
+ hashes.md5 = await helpers.calculateHash(fileData, 'md5');
366
+ }
367
+
368
+ if (formData.blake2_256_digest) {
369
+ hashes.blake2b = formData.blake2_256_digest;
370
+ }
371
+
372
+ // Store file
373
+ await this.storage.putPypiPackageFile(normalized, filename, fileData);
374
+
375
+ // Update metadata
376
+ let metadata = await this.storage.getPypiPackageMetadata(normalized);
377
+ if (!metadata) {
378
+ metadata = {
379
+ name: normalized,
380
+ versions: {},
381
+ };
382
+ }
383
+
384
+ if (!metadata.versions[version]) {
385
+ metadata.versions[version] = {
386
+ version,
387
+ files: [],
388
+ };
389
+ }
390
+
391
+ // Add file to version
392
+ metadata.versions[version].files.push({
393
+ filename,
394
+ path: `pypi/packages/${normalized}/${filename}`,
395
+ filetype,
396
+ python_version: pyversion,
397
+ hashes,
398
+ size: fileData.length,
399
+ 'requires-python': formData.requires_python,
400
+ 'upload-time': new Date().toISOString(),
401
+ 'uploaded-by': token.userId,
402
+ });
403
+
404
+ // Store core metadata if provided
405
+ if (formData.summary || formData.description) {
406
+ metadata.versions[version].metadata = helpers.extractCoreMetadata(formData);
407
+ }
408
+
409
+ metadata['last-modified'] = new Date().toISOString();
410
+ await this.storage.putPypiPackageMetadata(normalized, metadata);
411
+
412
+ this.logger.log('info', `Package uploaded: ${normalized} ${version}`, {
413
+ filename,
414
+ size: fileData.length
415
+ });
416
+
417
+ return {
418
+ status: 200,
419
+ headers: { 'Content-Type': 'application/json' },
420
+ body: Buffer.from(JSON.stringify({
421
+ message: 'Package uploaded successfully',
422
+ url: `${this.registryUrl}/pypi/packages/${normalized}/${filename}`
423
+ })),
424
+ };
425
+ } catch (error) {
426
+ this.logger.log('error', 'Upload failed', { error: (error as Error).message });
427
+ return this.errorResponse(500, 'Upload failed: ' + (error as Error).message);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Handle package download
433
+ */
434
+ private async handleDownload(packageName: string, filename: string): Promise<IResponse> {
435
+ const normalized = helpers.normalizePypiPackageName(packageName);
436
+ const fileData = await this.storage.getPypiPackageFile(normalized, filename);
437
+
438
+ if (!fileData) {
439
+ return {
440
+ status: 404,
441
+ headers: { 'Content-Type': 'application/json' },
442
+ body: Buffer.from(JSON.stringify({ message: 'File not found' })),
443
+ };
444
+ }
445
+
446
+ return {
447
+ status: 200,
448
+ headers: {
449
+ 'Content-Type': 'application/octet-stream',
450
+ 'Content-Disposition': `attachment; filename="${filename}"`,
451
+ 'Content-Length': fileData.length.toString()
452
+ },
453
+ body: fileData,
454
+ };
455
+ }
456
+
457
+ /**
458
+ * Handle package JSON API (all versions)
459
+ */
460
+ private async handlePackageJson(packageName: string): Promise<IResponse> {
461
+ const normalized = helpers.normalizePypiPackageName(packageName);
462
+ const metadata = await this.storage.getPypiPackageMetadata(normalized);
463
+
464
+ if (!metadata) {
465
+ return this.errorResponse(404, 'Package not found');
466
+ }
467
+
468
+ return {
469
+ status: 200,
470
+ headers: {
471
+ 'Content-Type': 'application/json',
472
+ 'Cache-Control': 'public, max-age=300'
473
+ },
474
+ body: Buffer.from(JSON.stringify(metadata)),
475
+ };
476
+ }
477
+
478
+ /**
479
+ * Handle version-specific JSON API
480
+ */
481
+ private async handleVersionJson(packageName: string, version: string): Promise<IResponse> {
482
+ const normalized = helpers.normalizePypiPackageName(packageName);
483
+ const metadata = await this.storage.getPypiPackageMetadata(normalized);
484
+
485
+ if (!metadata || !metadata.versions[version]) {
486
+ return this.errorResponse(404, 'Version not found');
487
+ }
488
+
489
+ return {
490
+ status: 200,
491
+ headers: {
492
+ 'Content-Type': 'application/json',
493
+ 'Cache-Control': 'public, max-age=300'
494
+ },
495
+ body: Buffer.from(JSON.stringify(metadata.versions[version])),
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Handle package deletion
501
+ */
502
+ private async handleDeletePackage(packageName: string, token: IAuthToken | null): Promise<IResponse> {
503
+ if (!token) {
504
+ return this.errorResponse(401, 'Authentication required');
505
+ }
506
+
507
+ const normalized = helpers.normalizePypiPackageName(packageName);
508
+
509
+ if (!(await this.checkPermission(token, normalized, 'delete'))) {
510
+ return this.errorResponse(403, 'Insufficient permissions');
511
+ }
512
+
513
+ await this.storage.deletePypiPackage(normalized);
514
+
515
+ this.logger.log('info', `Package deleted: ${normalized}`);
516
+
517
+ return {
518
+ status: 204,
519
+ headers: {},
520
+ body: Buffer.from(''),
521
+ };
522
+ }
523
+
524
+ /**
525
+ * Handle version deletion
526
+ */
527
+ private async handleDeleteVersion(
528
+ packageName: string,
529
+ version: string,
530
+ token: IAuthToken | null
531
+ ): Promise<IResponse> {
532
+ if (!token) {
533
+ return this.errorResponse(401, 'Authentication required');
534
+ }
535
+
536
+ const normalized = helpers.normalizePypiPackageName(packageName);
537
+
538
+ if (!(await this.checkPermission(token, normalized, 'delete'))) {
539
+ return this.errorResponse(403, 'Insufficient permissions');
540
+ }
541
+
542
+ await this.storage.deletePypiPackageVersion(normalized, version);
543
+
544
+ this.logger.log('info', `Version deleted: ${normalized} ${version}`);
545
+
546
+ return {
547
+ status: 204,
548
+ headers: {},
549
+ body: Buffer.from(''),
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Helper: Create error response
555
+ */
556
+ private errorResponse(status: number, message: string): IResponse {
557
+ const error: IPypiError = { message, status };
558
+ return {
559
+ status,
560
+ headers: { 'Content-Type': 'application/json' },
561
+ body: Buffer.from(JSON.stringify(error)),
562
+ };
563
+ }
564
+ }