@push.rocks/smartregistry 1.4.0 → 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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/composer/classes.composerregistry.js +3 -17
- package/dist_ts/core/classes.authmanager.d.ts +55 -1
- package/dist_ts/core/classes.authmanager.js +138 -3
- package/dist_ts/core/classes.registrystorage.d.ts +68 -0
- package/dist_ts/core/classes.registrystorage.js +195 -1
- package/dist_ts/core/interfaces.core.d.ts +13 -1
- package/dist_ts/pypi/classes.pypiregistry.d.ts +70 -0
- package/dist_ts/pypi/classes.pypiregistry.js +470 -0
- package/dist_ts/pypi/helpers.pypi.d.ts +84 -0
- package/dist_ts/pypi/helpers.pypi.js +263 -0
- package/dist_ts/pypi/index.d.ts +7 -0
- package/dist_ts/pypi/index.js +8 -0
- package/dist_ts/pypi/interfaces.pypi.d.ts +301 -0
- package/dist_ts/pypi/interfaces.pypi.js +6 -0
- package/package.json +1 -1
- package/readme.hints.md +333 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/composer/classes.composerregistry.ts +2 -18
- package/ts/core/classes.authmanager.ts +161 -2
- package/ts/core/classes.registrystorage.ts +227 -0
- package/ts/core/interfaces.core.ts +13 -1
- package/ts/pypi/classes.pypiregistry.ts +564 -0
- package/ts/pypi/helpers.pypi.ts +299 -0
- package/ts/pypi/index.ts +8 -0
- package/ts/pypi/interfaces.pypi.ts +316 -0
|
@@ -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
|
+
}
|