300 lines
7.7 KiB
TypeScript
300 lines
7.7 KiB
TypeScript
|
|
/**
|
||
|
|
* 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'],
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
}
|