2025-11-21 14:23:18 +00:00
|
|
|
import { Smartlog } from '@push.rocks/smartlog';
|
|
|
|
|
import { BaseRegistry } from '../core/classes.baseregistry.js';
|
|
|
|
|
import { RegistryStorage } from '../core/classes.registrystorage.js';
|
|
|
|
|
import { AuthManager } from '../core/classes.authmanager.js';
|
|
|
|
|
import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js';
|
|
|
|
|
import type {
|
|
|
|
|
IPypiPackageMetadata,
|
|
|
|
|
IPypiFile,
|
|
|
|
|
IPypiError,
|
|
|
|
|
IPypiUploadResponse,
|
|
|
|
|
} from './interfaces.pypi.js';
|
|
|
|
|
import * as helpers from './helpers.pypi.js';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* PyPI registry implementation
|
|
|
|
|
* Implements PEP 503 (Simple API), PEP 691 (JSON API), and legacy upload API
|
|
|
|
|
*/
|
|
|
|
|
export class PypiRegistry extends BaseRegistry {
|
|
|
|
|
private storage: RegistryStorage;
|
|
|
|
|
private authManager: AuthManager;
|
|
|
|
|
private basePath: string = '/pypi';
|
|
|
|
|
private registryUrl: string;
|
|
|
|
|
private logger: Smartlog;
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
storage: RegistryStorage,
|
|
|
|
|
authManager: AuthManager,
|
|
|
|
|
basePath: string = '/pypi',
|
|
|
|
|
registryUrl: string = 'http://localhost:5000'
|
|
|
|
|
) {
|
|
|
|
|
super();
|
|
|
|
|
this.storage = storage;
|
|
|
|
|
this.authManager = authManager;
|
|
|
|
|
this.basePath = basePath;
|
|
|
|
|
this.registryUrl = registryUrl;
|
|
|
|
|
|
|
|
|
|
// Initialize logger
|
|
|
|
|
this.logger = new Smartlog({
|
|
|
|
|
logContext: {
|
|
|
|
|
company: 'push.rocks',
|
|
|
|
|
companyunit: 'smartregistry',
|
|
|
|
|
containerName: 'pypi-registry',
|
|
|
|
|
environment: (process.env.NODE_ENV as any) || 'development',
|
|
|
|
|
runtime: 'node',
|
|
|
|
|
zone: 'pypi'
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
this.logger.enableConsole();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async init(): Promise<void> {
|
|
|
|
|
// Initialize root Simple API index if not exists
|
|
|
|
|
const existingIndex = await this.storage.getPypiSimpleRootIndex();
|
|
|
|
|
if (!existingIndex) {
|
|
|
|
|
const html = helpers.generateSimpleRootHtml([]);
|
|
|
|
|
await this.storage.putPypiSimpleRootIndex(html);
|
|
|
|
|
this.logger.log('info', 'Initialized PyPI root index');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public getBasePath(): string {
|
|
|
|
|
return this.basePath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async handleRequest(context: IRequestContext): Promise<IResponse> {
|
|
|
|
|
let path = context.path.replace(this.basePath, '');
|
|
|
|
|
|
|
|
|
|
// Also handle /simple path prefix
|
|
|
|
|
if (path.startsWith('/simple')) {
|
|
|
|
|
path = path.replace('/simple', '');
|
|
|
|
|
return this.handleSimpleRequest(path, context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract token (Basic Auth or Bearer)
|
|
|
|
|
const token = await this.extractToken(context);
|
|
|
|
|
|
|
|
|
|
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
|
|
|
|
|
method: context.method,
|
|
|
|
|
path,
|
|
|
|
|
hasAuth: !!token
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Root upload endpoint (POST /)
|
|
|
|
|
if ((path === '/' || path === '') && context.method === 'POST') {
|
|
|
|
|
return this.handleUpload(context, token);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 15:07:59 +00:00
|
|
|
// Package metadata JSON API: GET /{package}/json
|
|
|
|
|
const jsonMatch = path.match(/^\/([^\/]+)\/json$/);
|
2025-11-21 14:23:18 +00:00
|
|
|
if (jsonMatch && context.method === 'GET') {
|
|
|
|
|
return this.handlePackageJson(jsonMatch[1]);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 15:07:59 +00:00
|
|
|
// Version-specific JSON API: GET /{package}/{version}/json
|
|
|
|
|
const versionJsonMatch = path.match(/^\/([^\/]+)\/([^\/]+)\/json$/);
|
2025-11-21 14:23:18 +00:00
|
|
|
if (versionJsonMatch && context.method === 'GET') {
|
|
|
|
|
return this.handleVersionJson(versionJsonMatch[1], versionJsonMatch[2]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Package file download: GET /packages/{package}/{filename}
|
|
|
|
|
const downloadMatch = path.match(/^\/packages\/([^\/]+)\/(.+)$/);
|
|
|
|
|
if (downloadMatch && context.method === 'GET') {
|
|
|
|
|
return this.handleDownload(downloadMatch[1], downloadMatch[2]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete package: DELETE /packages/{package}
|
|
|
|
|
if (path.match(/^\/packages\/([^\/]+)$/) && context.method === 'DELETE') {
|
|
|
|
|
const packageName = path.match(/^\/packages\/([^\/]+)$/)?.[1];
|
|
|
|
|
return this.handleDeletePackage(packageName!, token);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete version: DELETE /packages/{package}/{version}
|
|
|
|
|
const deleteVersionMatch = path.match(/^\/packages\/([^\/]+)\/([^\/]+)$/);
|
|
|
|
|
if (deleteVersionMatch && context.method === 'DELETE') {
|
|
|
|
|
return this.handleDeleteVersion(deleteVersionMatch[1], deleteVersionMatch[2], token);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: 404,
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2025-11-25 15:07:59 +00:00
|
|
|
body: { error: 'Not Found' },
|
2025-11-21 14:23:18 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if token has permission for resource
|
|
|
|
|
*/
|
|
|
|
|
protected async checkPermission(
|
|
|
|
|
token: IAuthToken | null,
|
|
|
|
|
resource: string,
|
|
|
|
|
action: string
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
if (!token) return false;
|
|
|
|
|
return this.authManager.authorize(token, `pypi:package:${resource}`, action);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle Simple API requests (PEP 503 HTML or PEP 691 JSON)
|
|
|
|
|
*/
|
|
|
|
|
private async handleSimpleRequest(path: string, context: IRequestContext): Promise<IResponse> {
|
|
|
|
|
// Ensure path ends with / (PEP 503 requirement)
|
|
|
|
|
if (!path.endsWith('/') && !path.includes('.')) {
|
|
|
|
|
return {
|
|
|
|
|
status: 301,
|
|
|
|
|
headers: { 'Location': `${this.basePath}/simple${path}/` },
|
|
|
|
|
body: Buffer.from(''),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Root index: /simple/
|
|
|
|
|
if (path === '/' || path === '') {
|
|
|
|
|
return this.handleSimpleRoot(context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Package index: /simple/{package}/
|
|
|
|
|
const packageMatch = path.match(/^\/([^\/]+)\/$/);
|
|
|
|
|
if (packageMatch) {
|
|
|
|
|
return this.handleSimplePackage(packageMatch[1], context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: 404,
|
|
|
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
|
|
|
body: Buffer.from('<html><body><h1>404 Not Found</h1></body></html>'),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle Simple API root index
|
|
|
|
|
* Returns HTML (PEP 503) or JSON (PEP 691) based on Accept header
|
|
|
|
|
*/
|
|
|
|
|
private async handleSimpleRoot(context: IRequestContext): Promise<IResponse> {
|
|
|
|
|
const acceptHeader = context.headers['accept'] || context.headers['Accept'] || '';
|
|
|
|
|
const preferJson = acceptHeader.includes('application/vnd.pypi.simple') &&
|
|
|
|
|
acceptHeader.includes('json');
|
|
|
|
|
|
|
|
|
|
const packages = await this.storage.listPypiPackages();
|
|
|
|
|
|
|
|
|
|
if (preferJson) {
|
|
|
|
|
// PEP 691: JSON response
|
|
|
|
|
const response = helpers.generateJsonRootResponse(packages);
|
|
|
|
|
return {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/vnd.pypi.simple.v1+json',
|
|
|
|
|
'Cache-Control': 'public, max-age=600'
|
|
|
|
|
},
|
2025-11-25 14:28:19 +00:00
|
|
|
body: response,
|
2025-11-21 14:23:18 +00:00
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
// PEP 503: HTML response
|
|
|
|
|
const html = helpers.generateSimpleRootHtml(packages);
|
|
|
|
|
|
|
|
|
|
// Update stored index
|
|
|
|
|
await this.storage.putPypiSimpleRootIndex(html);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
|
|
|
'Cache-Control': 'public, max-age=600'
|
|
|
|
|
},
|
2025-11-25 14:28:19 +00:00
|
|
|
body: html,
|
2025-11-21 14:23:18 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle Simple API package index
|
|
|
|
|
* Returns HTML (PEP 503) or JSON (PEP 691) based on Accept header
|
|
|
|
|
*/
|
|
|
|
|
private async handleSimplePackage(packageName: string, context: IRequestContext): Promise<IResponse> {
|
|
|
|
|
const normalized = helpers.normalizePypiPackageName(packageName);
|
|
|
|
|
|
|
|
|
|
// Get package metadata
|
|
|
|
|
const metadata = await this.storage.getPypiPackageMetadata(normalized);
|
|
|
|
|
if (!metadata) {
|
2025-11-25 15:07:59 +00:00
|
|
|
return this.errorResponse(404, 'Package not found');
|
2025-11-21 14:23:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build file list from all versions
|
|
|
|
|
const files: IPypiFile[] = [];
|
|
|
|
|
for (const [version, versionMeta] of Object.entries(metadata.versions || {})) {
|
|
|
|
|
for (const file of (versionMeta as any).files || []) {
|
|
|
|
|
files.push({
|
|
|
|
|
filename: file.filename,
|
|
|
|
|
url: `${this.registryUrl}/pypi/packages/${normalized}/${file.filename}`,
|
|
|
|
|
hashes: file.hashes,
|
|
|
|
|
'requires-python': file['requires-python'],
|
|
|
|
|
yanked: file.yanked || (versionMeta as any).yanked,
|
|
|
|
|
size: file.size,
|
|
|
|
|
'upload-time': file['upload-time'],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const acceptHeader = context.headers['accept'] || context.headers['Accept'] || '';
|
|
|
|
|
const preferJson = acceptHeader.includes('application/vnd.pypi.simple') &&
|
|
|
|
|
acceptHeader.includes('json');
|
|
|
|
|
|
|
|
|
|
if (preferJson) {
|
|
|
|
|
// PEP 691: JSON response
|
|
|
|
|
const response = helpers.generateJsonPackageResponse(normalized, files);
|
|
|
|
|
return {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/vnd.pypi.simple.v1+json',
|
|
|
|
|
'Cache-Control': 'public, max-age=300'
|
|
|
|
|
},
|
2025-11-25 14:28:19 +00:00
|
|
|
body: response,
|
2025-11-21 14:23:18 +00:00
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
// PEP 503: HTML response
|
|
|
|
|
const html = helpers.generateSimplePackageHtml(normalized, files, this.registryUrl);
|
|
|
|
|
|
|
|
|
|
// Update stored index
|
|
|
|
|
await this.storage.putPypiSimpleIndex(normalized, html);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
|
|
|
'Cache-Control': 'public, max-age=300'
|
|
|
|
|
},
|
2025-11-25 14:28:19 +00:00
|
|
|
body: html,
|
2025-11-21 14:23:18 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extract authentication token from request
|
|
|
|
|
*/
|
|
|
|
|
private async extractToken(context: IRequestContext): Promise<IAuthToken | null> {
|
|
|
|
|
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
|
|
|
|
|
if (!authHeader) return null;
|
|
|
|
|
|
|
|
|
|
// Handle Basic Auth (username:password or __token__:token)
|
|
|
|
|
if (authHeader.startsWith('Basic ')) {
|
|
|
|
|
const base64 = authHeader.substring(6);
|
|
|
|
|
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
|
|
|
|
|
const [username, password] = decoded.split(':');
|
|
|
|
|
|
|
|
|
|
// PyPI token authentication: username = __token__
|
|
|
|
|
if (username === '__token__') {
|
|
|
|
|
return this.authManager.validateToken(password, 'pypi');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Username/password authentication (would need user lookup)
|
|
|
|
|
// For now, not implemented
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle Bearer token
|
|
|
|
|
if (authHeader.startsWith('Bearer ')) {
|
|
|
|
|
const token = authHeader.substring(7);
|
|
|
|
|
return this.authManager.validateToken(token, 'pypi');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle package upload (multipart/form-data)
|
|
|
|
|
* POST / with :action=file_upload
|
|
|
|
|
*/
|
|
|
|
|
private async handleUpload(context: IRequestContext, token: IAuthToken | null): Promise<IResponse> {
|
|
|
|
|
if (!token) {
|
|
|
|
|
return {
|
|
|
|
|
status: 401,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'WWW-Authenticate': 'Basic realm="PyPI"'
|
|
|
|
|
},
|
2025-11-25 15:07:59 +00:00
|
|
|
body: { error: 'Authentication required' },
|
2025-11-21 14:23:18 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Parse multipart form data (context.body should be parsed by server)
|
|
|
|
|
const formData = context.body as any; // Assuming parsed multipart data
|
|
|
|
|
|
|
|
|
|
if (!formData || formData[':action'] !== 'file_upload') {
|
|
|
|
|
return this.errorResponse(400, 'Invalid upload request');
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 14:28:19 +00:00
|
|
|
// Extract required fields - support both nested and flat body formats
|
2025-11-21 14:23:18 +00:00
|
|
|
const packageName = formData.name;
|
|
|
|
|
const version = formData.version;
|
2025-11-25 14:28:19 +00:00
|
|
|
// Support both: formData.content.filename (multipart parsed) and formData.filename (flat)
|
|
|
|
|
const filename = formData.content?.filename || formData.filename;
|
|
|
|
|
// Support both: formData.content.data (multipart parsed) and formData.content (Buffer directly)
|
|
|
|
|
const fileData = (formData.content?.data || (Buffer.isBuffer(formData.content) ? formData.content : null)) as Buffer;
|
2025-11-21 14:23:18 +00:00
|
|
|
const filetype = formData.filetype; // 'bdist_wheel' or 'sdist'
|
|
|
|
|
const pyversion = formData.pyversion;
|
|
|
|
|
|
|
|
|
|
if (!packageName || !version || !filename || !fileData) {
|
|
|
|
|
return this.errorResponse(400, 'Missing required fields');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate package name
|
|
|
|
|
if (!helpers.isValidPackageName(packageName)) {
|
|
|
|
|
return this.errorResponse(400, 'Invalid package name');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalized = helpers.normalizePypiPackageName(packageName);
|
|
|
|
|
|
|
|
|
|
// Check permission
|
|
|
|
|
if (!(await this.checkPermission(token, normalized, 'write'))) {
|
|
|
|
|
return this.errorResponse(403, 'Insufficient permissions');
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-21 17:13:06 +00:00
|
|
|
// Calculate and verify hashes
|
2025-11-21 14:23:18 +00:00
|
|
|
const hashes: Record<string, string> = {};
|
|
|
|
|
|
2025-11-21 17:13:06 +00:00
|
|
|
// Always calculate SHA256
|
|
|
|
|
const actualSha256 = await helpers.calculateHash(fileData, 'sha256');
|
|
|
|
|
hashes.sha256 = actualSha256;
|
|
|
|
|
|
|
|
|
|
// Verify client-provided SHA256 if present
|
|
|
|
|
if (formData.sha256_digest && formData.sha256_digest !== actualSha256) {
|
|
|
|
|
return this.errorResponse(400, 'SHA256 hash mismatch');
|
2025-11-21 14:23:18 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-21 17:13:06 +00:00
|
|
|
// Calculate MD5 if requested
|
2025-11-21 14:23:18 +00:00
|
|
|
if (formData.md5_digest) {
|
2025-11-21 17:13:06 +00:00
|
|
|
const actualMd5 = await helpers.calculateHash(fileData, 'md5');
|
|
|
|
|
hashes.md5 = actualMd5;
|
|
|
|
|
|
|
|
|
|
// Verify if client provided MD5
|
|
|
|
|
if (formData.md5_digest !== actualMd5) {
|
|
|
|
|
return this.errorResponse(400, 'MD5 hash mismatch');
|
|
|
|
|
}
|
2025-11-21 14:23:18 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-21 17:13:06 +00:00
|
|
|
// Calculate Blake2b if requested
|
2025-11-21 14:23:18 +00:00
|
|
|
if (formData.blake2_256_digest) {
|
2025-11-21 17:13:06 +00:00
|
|
|
const actualBlake2b = await helpers.calculateHash(fileData, 'blake2b');
|
|
|
|
|
hashes.blake2b = actualBlake2b;
|
|
|
|
|
|
|
|
|
|
// Verify if client provided Blake2b
|
|
|
|
|
if (formData.blake2_256_digest !== actualBlake2b) {
|
|
|
|
|
return this.errorResponse(400, 'Blake2b hash mismatch');
|
|
|
|
|
}
|
2025-11-21 14:23:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store file
|
|
|
|
|
await this.storage.putPypiPackageFile(normalized, filename, fileData);
|
|
|
|
|
|
|
|
|
|
// Update metadata
|
|
|
|
|
let metadata = await this.storage.getPypiPackageMetadata(normalized);
|
|
|
|
|
if (!metadata) {
|
|
|
|
|
metadata = {
|
|
|
|
|
name: normalized,
|
|
|
|
|
versions: {},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!metadata.versions[version]) {
|
|
|
|
|
metadata.versions[version] = {
|
|
|
|
|
version,
|
|
|
|
|
files: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add file to version
|
|
|
|
|
metadata.versions[version].files.push({
|
|
|
|
|
filename,
|
|
|
|
|
path: `pypi/packages/${normalized}/${filename}`,
|
|
|
|
|
filetype,
|
|
|
|
|
python_version: pyversion,
|
|
|
|
|
hashes,
|
|
|
|
|
size: fileData.length,
|
|
|
|
|
'requires-python': formData.requires_python,
|
|
|
|
|
'upload-time': new Date().toISOString(),
|
|
|
|
|
'uploaded-by': token.userId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Store core metadata if provided
|
|
|
|
|
if (formData.summary || formData.description) {
|
|
|
|
|
metadata.versions[version].metadata = helpers.extractCoreMetadata(formData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
metadata['last-modified'] = new Date().toISOString();
|
|
|
|
|
await this.storage.putPypiPackageMetadata(normalized, metadata);
|
|
|
|
|
|
|
|
|
|
this.logger.log('info', `Package uploaded: ${normalized} ${version}`, {
|
|
|
|
|
filename,
|
|
|
|
|
size: fileData.length
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
2025-11-25 14:28:19 +00:00
|
|
|
status: 201,
|
2025-11-21 14:23:18 +00:00
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2025-11-25 15:07:59 +00:00
|
|
|
body: {
|
2025-11-21 14:23:18 +00:00
|
|
|
message: 'Package uploaded successfully',
|
|
|
|
|
url: `${this.registryUrl}/pypi/packages/${normalized}/${filename}`
|
2025-11-25 15:07:59 +00:00
|
|
|
},
|
2025-11-21 14:23:18 +00:00
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.log('error', 'Upload failed', { error: (error as Error).message });
|
|
|
|
|
return this.errorResponse(500, 'Upload failed: ' + (error as Error).message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle package download
|
|
|
|
|
*/
|
|
|
|
|
private async handleDownload(packageName: string, filename: string): Promise<IResponse> {
|
|
|
|
|
const normalized = helpers.normalizePypiPackageName(packageName);
|
|
|
|
|
const fileData = await this.storage.getPypiPackageFile(normalized, filename);
|
|
|
|
|
|
|
|
|
|
if (!fileData) {
|
|
|
|
|
return {
|
|
|
|
|
status: 404,
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2025-11-25 15:07:59 +00:00
|
|
|
body: { error: 'File not found' },
|
2025-11-21 14:23:18 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/octet-stream',
|
|
|
|
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
|
|
|
'Content-Length': fileData.length.toString()
|
|
|
|
|
},
|
|
|
|
|
body: fileData,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle package JSON API (all versions)
|
2025-11-25 15:07:59 +00:00
|
|
|
* Returns format compatible with official PyPI JSON API
|
2025-11-21 14:23:18 +00:00
|
|
|
*/
|
|
|
|
|
private async handlePackageJson(packageName: string): Promise<IResponse> {
|
|
|
|
|
const normalized = helpers.normalizePypiPackageName(packageName);
|
|
|
|
|
const metadata = await this.storage.getPypiPackageMetadata(normalized);
|
|
|
|
|
|
|
|
|
|
if (!metadata) {
|
|
|
|
|
return this.errorResponse(404, 'Package not found');
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 15:07:59 +00:00
|
|
|
// Find latest version for info
|
|
|
|
|
const versions = Object.keys(metadata.versions || {});
|
|
|
|
|
const latestVersion = versions.length > 0 ? versions[versions.length - 1] : null;
|
|
|
|
|
const latestMeta = latestVersion ? metadata.versions[latestVersion] : null;
|
|
|
|
|
|
|
|
|
|
// Build URLs array from latest version files
|
|
|
|
|
const urls = latestMeta?.files?.map((file: any) => ({
|
|
|
|
|
filename: file.filename,
|
|
|
|
|
url: `${this.registryUrl}/pypi/packages/${normalized}/${file.filename}`,
|
|
|
|
|
digests: file.hashes,
|
|
|
|
|
requires_python: file['requires-python'],
|
|
|
|
|
size: file.size,
|
|
|
|
|
upload_time: file['upload-time'],
|
|
|
|
|
packagetype: file.filetype,
|
|
|
|
|
python_version: file.python_version,
|
|
|
|
|
})) || [];
|
|
|
|
|
|
|
|
|
|
// Build releases object
|
|
|
|
|
const releases: Record<string, any[]> = {};
|
|
|
|
|
for (const [ver, verMeta] of Object.entries(metadata.versions || {})) {
|
|
|
|
|
releases[ver] = (verMeta as any).files?.map((file: any) => ({
|
|
|
|
|
filename: file.filename,
|
|
|
|
|
url: `${this.registryUrl}/pypi/packages/${normalized}/${file.filename}`,
|
|
|
|
|
digests: file.hashes,
|
|
|
|
|
requires_python: file['requires-python'],
|
|
|
|
|
size: file.size,
|
|
|
|
|
upload_time: file['upload-time'],
|
|
|
|
|
packagetype: file.filetype,
|
|
|
|
|
python_version: file.python_version,
|
|
|
|
|
})) || [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = {
|
|
|
|
|
info: {
|
|
|
|
|
name: normalized,
|
|
|
|
|
version: latestVersion,
|
|
|
|
|
summary: latestMeta?.metadata?.summary,
|
|
|
|
|
description: latestMeta?.metadata?.description,
|
|
|
|
|
author: latestMeta?.metadata?.author,
|
|
|
|
|
author_email: latestMeta?.metadata?.['author-email'],
|
|
|
|
|
license: latestMeta?.metadata?.license,
|
|
|
|
|
requires_python: latestMeta?.files?.[0]?.['requires-python'],
|
|
|
|
|
...latestMeta?.metadata,
|
|
|
|
|
},
|
|
|
|
|
urls,
|
|
|
|
|
releases,
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-21 14:23:18 +00:00
|
|
|
return {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Cache-Control': 'public, max-age=300'
|
|
|
|
|
},
|
2025-11-25 15:07:59 +00:00
|
|
|
body: response,
|
2025-11-21 14:23:18 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle version-specific JSON API
|
2025-11-25 15:07:59 +00:00
|
|
|
* Returns format compatible with official PyPI JSON API
|
2025-11-21 14:23:18 +00:00
|
|
|
*/
|
|
|
|
|
private async handleVersionJson(packageName: string, version: string): Promise<IResponse> {
|
|
|
|
|
const normalized = helpers.normalizePypiPackageName(packageName);
|
|
|
|
|
const metadata = await this.storage.getPypiPackageMetadata(normalized);
|
|
|
|
|
|
|
|
|
|
if (!metadata || !metadata.versions[version]) {
|
|
|
|
|
return this.errorResponse(404, 'Version not found');
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 15:07:59 +00:00
|
|
|
const verMeta = metadata.versions[version];
|
|
|
|
|
|
|
|
|
|
// Build URLs array from version files
|
|
|
|
|
const urls = verMeta.files?.map((file: any) => ({
|
|
|
|
|
filename: file.filename,
|
|
|
|
|
url: `${this.registryUrl}/pypi/packages/${normalized}/${file.filename}`,
|
|
|
|
|
digests: file.hashes,
|
|
|
|
|
requires_python: file['requires-python'],
|
|
|
|
|
size: file.size,
|
|
|
|
|
upload_time: file['upload-time'],
|
|
|
|
|
packagetype: file.filetype,
|
|
|
|
|
python_version: file.python_version,
|
|
|
|
|
})) || [];
|
|
|
|
|
|
|
|
|
|
const response = {
|
|
|
|
|
info: {
|
|
|
|
|
name: normalized,
|
|
|
|
|
version,
|
|
|
|
|
summary: verMeta.metadata?.summary,
|
|
|
|
|
description: verMeta.metadata?.description,
|
|
|
|
|
author: verMeta.metadata?.author,
|
|
|
|
|
author_email: verMeta.metadata?.['author-email'],
|
|
|
|
|
license: verMeta.metadata?.license,
|
|
|
|
|
requires_python: verMeta.files?.[0]?.['requires-python'],
|
|
|
|
|
...verMeta.metadata,
|
|
|
|
|
},
|
|
|
|
|
urls,
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-21 14:23:18 +00:00
|
|
|
return {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Cache-Control': 'public, max-age=300'
|
|
|
|
|
},
|
2025-11-25 15:07:59 +00:00
|
|
|
body: response,
|
2025-11-21 14:23:18 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle package deletion
|
|
|
|
|
*/
|
|
|
|
|
private async handleDeletePackage(packageName: string, token: IAuthToken | null): Promise<IResponse> {
|
|
|
|
|
if (!token) {
|
|
|
|
|
return this.errorResponse(401, 'Authentication required');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalized = helpers.normalizePypiPackageName(packageName);
|
|
|
|
|
|
|
|
|
|
if (!(await this.checkPermission(token, normalized, 'delete'))) {
|
|
|
|
|
return this.errorResponse(403, 'Insufficient permissions');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.storage.deletePypiPackage(normalized);
|
|
|
|
|
|
|
|
|
|
this.logger.log('info', `Package deleted: ${normalized}`);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: 204,
|
|
|
|
|
headers: {},
|
|
|
|
|
body: Buffer.from(''),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle version deletion
|
|
|
|
|
*/
|
|
|
|
|
private async handleDeleteVersion(
|
|
|
|
|
packageName: string,
|
|
|
|
|
version: string,
|
|
|
|
|
token: IAuthToken | null
|
|
|
|
|
): Promise<IResponse> {
|
|
|
|
|
if (!token) {
|
|
|
|
|
return this.errorResponse(401, 'Authentication required');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalized = helpers.normalizePypiPackageName(packageName);
|
|
|
|
|
|
|
|
|
|
if (!(await this.checkPermission(token, normalized, 'delete'))) {
|
|
|
|
|
return this.errorResponse(403, 'Insufficient permissions');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.storage.deletePypiPackageVersion(normalized, version);
|
|
|
|
|
|
|
|
|
|
this.logger.log('info', `Version deleted: ${normalized} ${version}`);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: 204,
|
|
|
|
|
headers: {},
|
|
|
|
|
body: Buffer.from(''),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Helper: Create error response
|
|
|
|
|
*/
|
|
|
|
|
private errorResponse(status: number, message: string): IResponse {
|
2025-11-25 15:07:59 +00:00
|
|
|
const error: IPypiError = { error: message, status };
|
2025-11-21 14:23:18 +00:00
|
|
|
return {
|
|
|
|
|
status,
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2025-11-25 15:07:59 +00:00
|
|
|
body: error,
|
2025-11-21 14:23:18 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|