feat(core): Add PyPI and RubyGems protocol support, Cargo token management, and storage helpers

This commit is contained in:
2025-11-21 14:23:18 +00:00
parent fb8d6897e3
commit 9ca1e670ef
12 changed files with 2414 additions and 6 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartregistry',
version: '1.4.1',
version: '1.5.0',
description: 'a registry for npm modules and oci images'
}

View File

@@ -317,12 +317,153 @@ export class AuthManager {
this.tokenStore.delete(token);
}
// ========================================================================
// CARGO TOKEN MANAGEMENT
// ========================================================================
/**
* Create a Cargo token
* @param userId - User ID
* @param readonly - Whether the token is readonly
* @returns Cargo UUID token
*/
public async createCargoToken(userId: string, readonly: boolean = false): Promise<string> {
const scopes = readonly ? ['cargo:*:*:read'] : ['cargo:*:*:*'];
return this.createUuidToken(userId, 'cargo', scopes, readonly);
}
/**
* Validate a Cargo token
* @param token - Cargo UUID token
* @returns Auth token object or null
*/
public async validateCargoToken(token: string): Promise<IAuthToken | null> {
if (!this.isValidUuid(token)) {
return null;
}
const authToken = this.tokenStore.get(token);
if (!authToken || authToken.type !== 'cargo') {
return null;
}
// Check expiration if set
if (authToken.expiresAt && authToken.expiresAt < new Date()) {
this.tokenStore.delete(token);
return null;
}
return authToken;
}
/**
* Revoke a Cargo token
* @param token - Cargo UUID token
*/
public async revokeCargoToken(token: string): Promise<void> {
this.tokenStore.delete(token);
}
// ========================================================================
// PYPI AUTHENTICATION
// ========================================================================
/**
* Create a PyPI token
* @param userId - User ID
* @param readonly - Whether the token is readonly
* @returns PyPI UUID token
*/
public async createPypiToken(userId: string, readonly: boolean = false): Promise<string> {
const scopes = readonly ? ['pypi:*:*:read'] : ['pypi:*:*:*'];
return this.createUuidToken(userId, 'pypi', scopes, readonly);
}
/**
* Validate a PyPI token
* @param token - PyPI UUID token
* @returns Auth token object or null
*/
public async validatePypiToken(token: string): Promise<IAuthToken | null> {
if (!this.isValidUuid(token)) {
return null;
}
const authToken = this.tokenStore.get(token);
if (!authToken || authToken.type !== 'pypi') {
return null;
}
// Check expiration if set
if (authToken.expiresAt && authToken.expiresAt < new Date()) {
this.tokenStore.delete(token);
return null;
}
return authToken;
}
/**
* Revoke a PyPI token
* @param token - PyPI UUID token
*/
public async revokePypiToken(token: string): Promise<void> {
this.tokenStore.delete(token);
}
// ========================================================================
// RUBYGEMS AUTHENTICATION
// ========================================================================
/**
* Create a RubyGems token
* @param userId - User ID
* @param readonly - Whether the token is readonly
* @returns RubyGems UUID token
*/
public async createRubyGemsToken(userId: string, readonly: boolean = false): Promise<string> {
const scopes = readonly ? ['rubygems:*:*:read'] : ['rubygems:*:*:*'];
return this.createUuidToken(userId, 'rubygems', scopes, readonly);
}
/**
* Validate a RubyGems token
* @param token - RubyGems UUID token
* @returns Auth token object or null
*/
public async validateRubyGemsToken(token: string): Promise<IAuthToken | null> {
if (!this.isValidUuid(token)) {
return null;
}
const authToken = this.tokenStore.get(token);
if (!authToken || authToken.type !== 'rubygems') {
return null;
}
// Check expiration if set
if (authToken.expiresAt && authToken.expiresAt < new Date()) {
this.tokenStore.delete(token);
return null;
}
return authToken;
}
/**
* Revoke a RubyGems token
* @param token - RubyGems UUID token
*/
public async revokeRubyGemsToken(token: string): Promise<void> {
this.tokenStore.delete(token);
}
// ========================================================================
// UNIFIED AUTHENTICATION
// ========================================================================
/**
* Validate any token (NPM, Maven, or OCI)
* Validate any token (NPM, Maven, OCI, PyPI, RubyGems, Composer, Cargo)
* @param tokenString - Token string (UUID or JWT)
* @param protocol - Expected protocol type
* @returns Auth token object or null
@@ -331,7 +472,7 @@ export class AuthManager {
tokenString: string,
protocol?: TRegistryProtocol
): Promise<IAuthToken | null> {
// Try UUID-based tokens (NPM, Maven, Composer)
// Try UUID-based tokens (NPM, Maven, Composer, Cargo, PyPI, RubyGems)
if (this.isValidUuid(tokenString)) {
// Try NPM token
const npmToken = await this.validateNpmToken(tokenString);
@@ -350,6 +491,24 @@ export class AuthManager {
if (composerToken && (!protocol || protocol === 'composer')) {
return composerToken;
}
// Try Cargo token
const cargoToken = await this.validateCargoToken(tokenString);
if (cargoToken && (!protocol || protocol === 'cargo')) {
return cargoToken;
}
// Try PyPI token
const pypiToken = await this.validatePypiToken(tokenString);
if (pypiToken && (!protocol || protocol === 'pypi')) {
return pypiToken;
}
// Try RubyGems token
const rubygemsToken = await this.validateRubyGemsToken(tokenString);
if (rubygemsToken && (!protocol || protocol === 'rubygems')) {
return rubygemsToken;
}
}
// Try OCI JWT

View File

@@ -601,4 +601,231 @@ export class RegistryStorage implements IStorageBackend {
private getComposerZipPath(vendorPackage: string, reference: string): string {
return `composer/packages/${vendorPackage}/${reference}.zip`;
}
// ========================================================================
// PYPI STORAGE METHODS
// ========================================================================
/**
* Get PyPI package metadata
*/
public async getPypiPackageMetadata(packageName: string): Promise<any | null> {
const path = this.getPypiMetadataPath(packageName);
const data = await this.getObject(path);
return data ? JSON.parse(data.toString('utf-8')) : null;
}
/**
* Store PyPI package metadata
*/
public async putPypiPackageMetadata(packageName: string, metadata: any): Promise<void> {
const path = this.getPypiMetadataPath(packageName);
const data = Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'application/json' });
}
/**
* Check if PyPI package metadata exists
*/
public async pypiPackageMetadataExists(packageName: string): Promise<boolean> {
const path = this.getPypiMetadataPath(packageName);
return this.objectExists(path);
}
/**
* Delete PyPI package metadata
*/
public async deletePypiPackageMetadata(packageName: string): Promise<void> {
const path = this.getPypiMetadataPath(packageName);
return this.deleteObject(path);
}
/**
* Get PyPI Simple API index (HTML)
*/
public async getPypiSimpleIndex(packageName: string): Promise<string | null> {
const path = this.getPypiSimpleIndexPath(packageName);
const data = await this.getObject(path);
return data ? data.toString('utf-8') : null;
}
/**
* Store PyPI Simple API index (HTML)
*/
public async putPypiSimpleIndex(packageName: string, html: string): Promise<void> {
const path = this.getPypiSimpleIndexPath(packageName);
const data = Buffer.from(html, 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'text/html; charset=utf-8' });
}
/**
* Get PyPI root Simple API index (HTML)
*/
public async getPypiSimpleRootIndex(): Promise<string | null> {
const path = this.getPypiSimpleRootIndexPath();
const data = await this.getObject(path);
return data ? data.toString('utf-8') : null;
}
/**
* Store PyPI root Simple API index (HTML)
*/
public async putPypiSimpleRootIndex(html: string): Promise<void> {
const path = this.getPypiSimpleRootIndexPath();
const data = Buffer.from(html, 'utf-8');
return this.putObject(path, data, { 'Content-Type': 'text/html; charset=utf-8' });
}
/**
* Get PyPI package file (wheel, sdist)
*/
public async getPypiPackageFile(packageName: string, filename: string): Promise<Buffer | null> {
const path = this.getPypiPackageFilePath(packageName, filename);
return this.getObject(path);
}
/**
* Store PyPI package file (wheel, sdist)
*/
public async putPypiPackageFile(
packageName: string,
filename: string,
data: Buffer
): Promise<void> {
const path = this.getPypiPackageFilePath(packageName, filename);
return this.putObject(path, data, { 'Content-Type': 'application/octet-stream' });
}
/**
* Check if PyPI package file exists
*/
public async pypiPackageFileExists(packageName: string, filename: string): Promise<boolean> {
const path = this.getPypiPackageFilePath(packageName, filename);
return this.objectExists(path);
}
/**
* Delete PyPI package file
*/
public async deletePypiPackageFile(packageName: string, filename: string): Promise<void> {
const path = this.getPypiPackageFilePath(packageName, filename);
return this.deleteObject(path);
}
/**
* List all PyPI packages
*/
public async listPypiPackages(): Promise<string[]> {
const prefix = 'pypi/metadata/';
const objects = await this.listObjects(prefix);
const packages = new Set<string>();
// Extract package names from paths like: pypi/metadata/package-name/metadata.json
for (const obj of objects) {
const match = obj.match(/^pypi\/metadata\/([^\/]+)\/metadata\.json$/);
if (match) {
packages.add(match[1]);
}
}
return Array.from(packages).sort();
}
/**
* List all versions of a PyPI package
*/
public async listPypiPackageVersions(packageName: string): Promise<string[]> {
const prefix = `pypi/packages/${packageName}/`;
const objects = await this.listObjects(prefix);
const versions = new Set<string>();
// Extract versions from filenames
for (const obj of objects) {
const filename = obj.split('/').pop();
if (!filename) continue;
// Extract version from wheel filename: package-1.0.0-py3-none-any.whl
// or sdist filename: package-1.0.0.tar.gz
const wheelMatch = filename.match(/^[^-]+-([^-]+)-.*\.whl$/);
const sdistMatch = filename.match(/^[^-]+-([^.]+)\.(tar\.gz|zip)$/);
if (wheelMatch) versions.add(wheelMatch[1]);
else if (sdistMatch) versions.add(sdistMatch[1]);
}
return Array.from(versions).sort();
}
/**
* Delete entire PyPI package (all versions and files)
*/
public async deletePypiPackage(packageName: string): Promise<void> {
// Delete metadata
await this.deletePypiPackageMetadata(packageName);
// Delete Simple API index
const simpleIndexPath = this.getPypiSimpleIndexPath(packageName);
try {
await this.deleteObject(simpleIndexPath);
} catch (error) {
// Ignore if doesn't exist
}
// Delete all package files
const prefix = `pypi/packages/${packageName}/`;
const objects = await this.listObjects(prefix);
for (const obj of objects) {
await this.deleteObject(obj);
}
}
/**
* Delete specific version of a PyPI package
*/
public async deletePypiPackageVersion(packageName: string, version: string): Promise<void> {
const prefix = `pypi/packages/${packageName}/`;
const objects = await this.listObjects(prefix);
// Delete all files matching this version
for (const obj of objects) {
const filename = obj.split('/').pop();
if (!filename) continue;
// Check if filename contains this version
const wheelMatch = filename.match(/^[^-]+-([^-]+)-.*\.whl$/);
const sdistMatch = filename.match(/^[^-]+-([^.]+)\.(tar\.gz|zip)$/);
const fileVersion = wheelMatch?.[1] || sdistMatch?.[1];
if (fileVersion === version) {
await this.deleteObject(obj);
}
}
// Update metadata to remove this version
const metadata = await this.getPypiPackageMetadata(packageName);
if (metadata && metadata.versions) {
delete metadata.versions[version];
await this.putPypiPackageMetadata(packageName, metadata);
}
}
// ========================================================================
// PYPI PATH HELPERS
// ========================================================================
private getPypiMetadataPath(packageName: string): string {
return `pypi/metadata/${packageName}/metadata.json`;
}
private getPypiSimpleIndexPath(packageName: string): string {
return `pypi/simple/${packageName}/index.html`;
}
private getPypiSimpleRootIndexPath(): string {
return `pypi/simple/index.html`;
}
private getPypiPackageFilePath(packageName: string, filename: string): string {
return `pypi/packages/${packageName}/${filename}`;
}
}

View File

@@ -5,7 +5,7 @@
/**
* Registry protocol types
*/
export type TRegistryProtocol = 'oci' | 'npm' | 'maven' | 'cargo' | 'composer';
export type TRegistryProtocol = 'oci' | 'npm' | 'maven' | 'cargo' | 'composer' | 'pypi' | 'rubygems';
/**
* Unified action types across protocols
@@ -70,6 +70,16 @@ export interface IAuthConfig {
realm: string;
service: string;
};
/** PyPI token settings */
pypiTokens?: {
enabled: boolean;
defaultReadonly?: boolean;
};
/** RubyGems token settings */
rubygemsTokens?: {
enabled: boolean;
defaultReadonly?: boolean;
};
}
/**
@@ -92,6 +102,8 @@ export interface IRegistryConfig {
maven?: IProtocolConfig;
cargo?: IProtocolConfig;
composer?: IProtocolConfig;
pypi?: IProtocolConfig;
rubygems?: IProtocolConfig;
}
/**

View File

@@ -0,0 +1,564 @@
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);
}
// Package metadata JSON API: GET /pypi/{package}/json
const jsonMatch = path.match(/^\/pypi\/([^\/]+)\/json$/);
if (jsonMatch && context.method === 'GET') {
return this.handlePackageJson(jsonMatch[1]);
}
// Version-specific JSON API: GET /pypi/{package}/{version}/json
const versionJsonMatch = path.match(/^\/pypi\/([^\/]+)\/([^\/]+)\/json$/);
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' },
body: Buffer.from(JSON.stringify({ message: 'Not Found' })),
};
}
/**
* 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'
},
body: Buffer.from(JSON.stringify(response)),
};
} 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'
},
body: Buffer.from(html),
};
}
}
/**
* 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) {
return {
status: 404,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
body: Buffer.from('<html><body><h1>404 Not Found</h1></body></html>'),
};
}
// 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'
},
body: Buffer.from(JSON.stringify(response)),
};
} 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'
},
body: Buffer.from(html),
};
}
}
/**
* 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"'
},
body: Buffer.from(JSON.stringify({ message: 'Authentication required' })),
};
}
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');
}
// Extract required fields
const packageName = formData.name;
const version = formData.version;
const filename = formData.content?.filename;
const fileData = formData.content?.data as Buffer;
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');
}
// Calculate hashes
const hashes: Record<string, string> = {};
if (formData.sha256_digest) {
hashes.sha256 = formData.sha256_digest;
} else {
hashes.sha256 = await helpers.calculateHash(fileData, 'sha256');
}
if (formData.md5_digest) {
// MD5 digest in PyPI is urlsafe base64, convert to hex
hashes.md5 = await helpers.calculateHash(fileData, 'md5');
}
if (formData.blake2_256_digest) {
hashes.blake2b = formData.blake2_256_digest;
}
// 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 {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: Buffer.from(JSON.stringify({
message: 'Package uploaded successfully',
url: `${this.registryUrl}/pypi/packages/${normalized}/${filename}`
})),
};
} 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' },
body: Buffer.from(JSON.stringify({ message: 'File not found' })),
};
}
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)
*/
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');
}
return {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300'
},
body: Buffer.from(JSON.stringify(metadata)),
};
}
/**
* Handle version-specific JSON API
*/
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');
}
return {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300'
},
body: Buffer.from(JSON.stringify(metadata.versions[version])),
};
}
/**
* 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 {
const error: IPypiError = { message, status };
return {
status,
headers: { 'Content-Type': 'application/json' },
body: Buffer.from(JSON.stringify(error)),
};
}
}

299
ts/pypi/helpers.pypi.ts Normal file
View File

@@ -0,0 +1,299 @@
/**
* Helper functions for PyPI registry
* Package name normalization, HTML generation, etc.
*/
import type { IPypiFile, IPypiPackageMetadata } from './interfaces.pypi.js';
/**
* Normalize package name according to PEP 503
* Lowercase and replace runs of [._-] with a single dash
* @param name - Package name
* @returns Normalized name
*/
export function normalizePypiPackageName(name: string): string {
return name
.toLowerCase()
.replace(/[-_.]+/g, '-');
}
/**
* Escape HTML special characters to prevent XSS
* @param str - String to escape
* @returns Escaped string
*/
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Generate PEP 503 compliant HTML for root index (all packages)
* @param packages - List of package names
* @returns HTML string
*/
export function generateSimpleRootHtml(packages: string[]): string {
const links = packages
.map(pkg => {
const normalized = normalizePypiPackageName(pkg);
return ` <a href="${escapeHtml(normalized)}/">${escapeHtml(pkg)}</a>`;
})
.join('\n');
return `<!DOCTYPE html>
<html>
<head>
<meta name="pypi:repository-version" content="1.0">
<title>Simple Index</title>
</head>
<body>
<h1>Simple Index</h1>
${links}
</body>
</html>`;
}
/**
* Generate PEP 503 compliant HTML for package index (file list)
* @param packageName - Package name (normalized)
* @param files - List of files
* @param baseUrl - Base URL for downloads
* @returns HTML string
*/
export function generateSimplePackageHtml(
packageName: string,
files: IPypiFile[],
baseUrl: string
): string {
const links = files
.map(file => {
// Build URL
let url = file.url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
// Relative URL - make it absolute
url = `${baseUrl}/packages/${packageName}/${file.filename}`;
}
// Add hash fragment
const hashName = Object.keys(file.hashes)[0];
const hashValue = file.hashes[hashName];
const fragment = hashName && hashValue ? `#${hashName}=${hashValue}` : '';
// Build data attributes
const dataAttrs: string[] = [];
if (file['requires-python']) {
const escaped = escapeHtml(file['requires-python']);
dataAttrs.push(`data-requires-python="${escaped}"`);
}
if (file['gpg-sig'] !== undefined) {
dataAttrs.push(`data-gpg-sig="${file['gpg-sig'] ? 'true' : 'false'}"`);
}
if (file.yanked) {
const reason = typeof file.yanked === 'string' ? file.yanked : '';
if (reason) {
dataAttrs.push(`data-yanked="${escapeHtml(reason)}"`);
} else {
dataAttrs.push(`data-yanked=""`);
}
}
const dataAttrStr = dataAttrs.length > 0 ? ' ' + dataAttrs.join(' ') : '';
return ` <a href="${escapeHtml(url)}${fragment}"${dataAttrStr}>${escapeHtml(file.filename)}</a>`;
})
.join('\n');
return `<!DOCTYPE html>
<html>
<head>
<meta name="pypi:repository-version" content="1.0">
<title>Links for ${escapeHtml(packageName)}</title>
</head>
<body>
<h1>Links for ${escapeHtml(packageName)}</h1>
${links}
</body>
</html>`;
}
/**
* Parse filename to extract package info
* Supports wheel and sdist formats
* @param filename - Package filename
* @returns Parsed info or null
*/
export function parsePackageFilename(filename: string): {
name: string;
version: string;
filetype: 'bdist_wheel' | 'sdist';
pythonVersion?: string;
} | null {
// Wheel format: {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl
const wheelMatch = filename.match(/^([a-zA-Z0-9_.-]+?)-([a-zA-Z0-9_.]+?)(?:-(\d+))?-([^-]+)-([^-]+)-([^-]+)\.whl$/);
if (wheelMatch) {
return {
name: wheelMatch[1],
version: wheelMatch[2],
filetype: 'bdist_wheel',
pythonVersion: wheelMatch[4],
};
}
// Sdist tar.gz format: {name}-{version}.tar.gz
const sdistTarMatch = filename.match(/^([a-zA-Z0-9_.-]+?)-([a-zA-Z0-9_.]+)\.tar\.gz$/);
if (sdistTarMatch) {
return {
name: sdistTarMatch[1],
version: sdistTarMatch[2],
filetype: 'sdist',
pythonVersion: 'source',
};
}
// Sdist zip format: {name}-{version}.zip
const sdistZipMatch = filename.match(/^([a-zA-Z0-9_.-]+?)-([a-zA-Z0-9_.]+)\.zip$/);
if (sdistZipMatch) {
return {
name: sdistZipMatch[1],
version: sdistZipMatch[2],
filetype: 'sdist',
pythonVersion: 'source',
};
}
return null;
}
/**
* Calculate hash digest for a buffer
* @param data - Data to hash
* @param algorithm - Hash algorithm (sha256, md5, blake2b)
* @returns Hex-encoded hash
*/
export async function calculateHash(data: Buffer, algorithm: 'sha256' | 'md5' | 'blake2b'): Promise<string> {
const crypto = await import('crypto');
let hash: any;
if (algorithm === 'blake2b') {
// Node.js uses 'blake2b512' for blake2b
hash = crypto.createHash('blake2b512');
} else {
hash = crypto.createHash(algorithm);
}
hash.update(data);
return hash.digest('hex');
}
/**
* Validate package name
* Must contain only ASCII letters, numbers, ., -, and _
* @param name - Package name
* @returns true if valid
*/
export function isValidPackageName(name: string): boolean {
return /^[a-zA-Z0-9._-]+$/.test(name);
}
/**
* Validate version string (basic check)
* @param version - Version string
* @returns true if valid
*/
export function isValidVersion(version: string): boolean {
// Basic check - allows numbers, letters, dots, hyphens, underscores
// More strict validation would follow PEP 440
return /^[a-zA-Z0-9._-]+$/.test(version);
}
/**
* Extract metadata from package metadata
* Filters and normalizes metadata fields
* @param metadata - Raw metadata object
* @returns Filtered metadata
*/
export function extractCoreMetadata(metadata: Record<string, any>): Record<string, any> {
const coreFields = [
'metadata-version',
'name',
'version',
'platform',
'supported-platform',
'summary',
'description',
'description-content-type',
'keywords',
'home-page',
'download-url',
'author',
'author-email',
'maintainer',
'maintainer-email',
'license',
'classifier',
'requires-python',
'requires-dist',
'requires-external',
'provides-dist',
'project-url',
'provides-extra',
];
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(metadata)) {
const normalizedKey = key.toLowerCase().replace(/_/g, '-');
if (coreFields.includes(normalizedKey)) {
result[normalizedKey] = value;
}
}
return result;
}
/**
* Generate JSON API response for package list (PEP 691)
* @param packages - List of package names
* @returns JSON object
*/
export function generateJsonRootResponse(packages: string[]): any {
return {
meta: {
'api-version': '1.0',
},
projects: packages.map(name => ({ name })),
};
}
/**
* Generate JSON API response for package files (PEP 691)
* @param packageName - Package name (normalized)
* @param files - List of files
* @returns JSON object
*/
export function generateJsonPackageResponse(packageName: string, files: IPypiFile[]): any {
return {
meta: {
'api-version': '1.0',
},
name: packageName,
files: files.map(file => ({
filename: file.filename,
url: file.url,
hashes: file.hashes,
'requires-python': file['requires-python'],
'dist-info-metadata': file['dist-info-metadata'],
'gpg-sig': file['gpg-sig'],
yanked: file.yanked,
size: file.size,
'upload-time': file['upload-time'],
})),
};
}

8
ts/pypi/index.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* PyPI Registry Module
* Python Package Index implementation
*/
export * from './interfaces.pypi.js';
export * from './classes.pypiregistry.js';
export * as pypiHelpers from './helpers.pypi.js';

316
ts/pypi/interfaces.pypi.ts Normal file
View File

@@ -0,0 +1,316 @@
/**
* PyPI Registry Type Definitions
* Compliant with PEP 503 (Simple API), PEP 691 (JSON API), and PyPI upload API
*/
/**
* File information for a package distribution
* Used in both PEP 503 HTML and PEP 691 JSON responses
*/
export interface IPypiFile {
/** Filename (e.g., "package-1.0.0-py3-none-any.whl") */
filename: string;
/** Download URL (absolute or relative) */
url: string;
/** Hash digests (multiple algorithms supported in JSON) */
hashes: Record<string, string>;
/** Python version requirement (PEP 345 format) */
'requires-python'?: string;
/** Whether distribution info metadata is available (PEP 658) */
'dist-info-metadata'?: boolean | { sha256: string };
/** Whether GPG signature is available */
'gpg-sig'?: boolean;
/** Yank status: false or reason string */
yanked?: boolean | string;
/** File size in bytes */
size?: number;
/** Upload timestamp */
'upload-time'?: string;
}
/**
* Package metadata stored internally
* Consolidated from multiple file uploads
*/
export interface IPypiPackageMetadata {
/** Normalized package name */
name: string;
/** Map of version to file list */
versions: Record<string, IPypiVersionMetadata>;
/** Timestamp of last update */
'last-modified'?: string;
}
/**
* Metadata for a specific version
*/
export interface IPypiVersionMetadata {
/** Version string */
version: string;
/** Files for this version (wheels, sdists) */
files: IPypiFileMetadata[];
/** Core metadata fields */
metadata?: IPypiCoreMetadata;
/** Whether entire version is yanked */
yanked?: boolean | string;
/** Upload timestamp */
'upload-time'?: string;
}
/**
* Internal file metadata
*/
export interface IPypiFileMetadata {
filename: string;
/** Storage key/path */
path: string;
/** File type: bdist_wheel or sdist */
filetype: 'bdist_wheel' | 'sdist';
/** Python version tag */
python_version: string;
/** Hash digests */
hashes: Record<string, string>;
/** File size in bytes */
size: number;
/** Python version requirement */
'requires-python'?: string;
/** Whether this file is yanked */
yanked?: boolean | string;
/** Upload timestamp */
'upload-time': string;
/** Uploader user ID */
'uploaded-by': string;
}
/**
* Core metadata fields (subset of PEP 566)
* These are extracted from package uploads
*/
export interface IPypiCoreMetadata {
/** Metadata version */
'metadata-version': string;
/** Package name */
name: string;
/** Version string */
version: string;
/** Platform compatibility */
platform?: string;
/** Supported platforms */
'supported-platform'?: string;
/** Summary/description */
summary?: string;
/** Long description */
description?: string;
/** Description content type (text/plain, text/markdown, text/x-rst) */
'description-content-type'?: string;
/** Keywords */
keywords?: string;
/** Homepage URL */
'home-page'?: string;
/** Download URL */
'download-url'?: string;
/** Author name */
author?: string;
/** Author email */
'author-email'?: string;
/** Maintainer name */
maintainer?: string;
/** Maintainer email */
'maintainer-email'?: string;
/** License */
license?: string;
/** Classifiers (Trove classifiers) */
classifier?: string[];
/** Python version requirement */
'requires-python'?: string;
/** Dist name requirement */
'requires-dist'?: string[];
/** External requirement */
'requires-external'?: string[];
/** Provides dist */
'provides-dist'?: string[];
/** Project URLs */
'project-url'?: string[];
/** Provides extra */
'provides-extra'?: string[];
}
/**
* PEP 503: Simple API root response (project list)
*/
export interface IPypiSimpleRootHtml {
/** List of project names */
projects: string[];
}
/**
* PEP 503: Simple API project response (file list)
*/
export interface IPypiSimpleProjectHtml {
/** Normalized project name */
name: string;
/** List of files */
files: IPypiFile[];
}
/**
* PEP 691: JSON API root response
*/
export interface IPypiJsonRoot {
/** API metadata */
meta: {
/** API version (e.g., "1.0") */
'api-version': string;
};
/** List of projects */
projects: Array<{
/** Project name */
name: string;
}>;
}
/**
* PEP 691: JSON API project response
*/
export interface IPypiJsonProject {
/** Normalized project name */
name: string;
/** API metadata */
meta: {
/** API version (e.g., "1.0") */
'api-version': string;
};
/** List of files */
files: IPypiFile[];
}
/**
* Upload form data (multipart/form-data fields)
* Based on PyPI legacy upload API
*/
export interface IPypiUploadForm {
/** Action type (always "file_upload") */
':action': 'file_upload';
/** Protocol version (always "1") */
protocol_version: '1';
/** File content (binary) */
content: Buffer;
/** File type */
filetype: 'bdist_wheel' | 'sdist';
/** Python version tag */
pyversion: string;
/** Package name */
name: string;
/** Version string */
version: string;
/** Metadata version */
metadata_version: string;
/** Hash digests (at least one required) */
md5_digest?: string;
sha256_digest?: string;
blake2_256_digest?: string;
/** Optional attestations */
attestations?: string; // JSON array
/** Optional core metadata fields */
summary?: string;
description?: string;
description_content_type?: string;
author?: string;
author_email?: string;
maintainer?: string;
maintainer_email?: string;
license?: string;
keywords?: string;
home_page?: string;
download_url?: string;
requires_python?: string;
classifiers?: string[];
platform?: string;
[key: string]: any; // Allow additional metadata fields
}
/**
* JSON API upload response
*/
export interface IPypiUploadResponse {
/** Success message */
message?: string;
/** URL of uploaded file */
url?: string;
}
/**
* Error response structure
*/
export interface IPypiError {
/** Error message */
message: string;
/** HTTP status code */
status?: number;
/** Additional error details */
details?: string[];
}
/**
* Search query parameters
*/
export interface IPypiSearchQuery {
/** Search term */
q?: string;
/** Page number */
page?: number;
/** Results per page */
per_page?: number;
}
/**
* Search result for a single package
*/
export interface IPypiSearchResult {
/** Package name */
name: string;
/** Latest version */
version: string;
/** Summary */
summary: string;
/** Description */
description?: string;
}
/**
* Search response structure
*/
export interface IPypiSearchResponse {
/** Search results */
results: IPypiSearchResult[];
/** Result count */
count: number;
/** Current page */
page: number;
/** Total pages */
pages: number;
}
/**
* Yank request
*/
export interface IPypiYankRequest {
/** Package name */
name: string;
/** Version to yank */
version: string;
/** Optional filename (specific file) */
filename?: string;
/** Reason for yanking */
reason?: string;
}
/**
* Yank response
*/
export interface IPypiYankResponse {
/** Success indicator */
success: boolean;
/** Message */
message?: string;
}