feat(core): Add PyPI and RubyGems protocol support, Cargo token management, and storage helpers
This commit is contained in:
299
ts/pypi/helpers.pypi.ts
Normal file
299
ts/pypi/helpers.pypi.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'],
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user