/** * Composer Registry Implementation * Compliant with Composer v2 repository API */ import { BaseRegistry } from '../core/classes.baseregistry.js'; import type { RegistryStorage } from '../core/classes.registrystorage.js'; import type { AuthManager } from '../core/classes.authmanager.js'; import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js'; import type { IComposerPackage, IComposerPackageMetadata, IComposerRepository, } from './interfaces.composer.js'; import { normalizeVersion, validateComposerJson, extractComposerJsonFromZip, calculateSha1, parseVendorPackage, generatePackagesJson, sortVersions, } from './helpers.composer.js'; export class ComposerRegistry extends BaseRegistry { private storage: RegistryStorage; private authManager: AuthManager; private basePath: string = '/composer'; private registryUrl: string; constructor( storage: RegistryStorage, authManager: AuthManager, basePath: string = '/composer', registryUrl: string = 'http://localhost:5000/composer' ) { super(); this.storage = storage; this.authManager = authManager; this.basePath = basePath; this.registryUrl = registryUrl; } public async init(): Promise { // Composer registry initialization } public getBasePath(): string { return this.basePath; } public async handleRequest(context: IRequestContext): Promise { const path = context.path.replace(this.basePath, ''); // Extract token from Authorization header const authHeader = context.headers['authorization'] || context.headers['Authorization']; let token: IAuthToken | null = null; if (authHeader) { if (authHeader.startsWith('Bearer ')) { const tokenString = authHeader.replace(/^Bearer\s+/i, ''); token = await this.authManager.validateToken(tokenString, 'composer'); } else if (authHeader.startsWith('Basic ')) { // Handle HTTP Basic Auth const credentials = Buffer.from(authHeader.replace(/^Basic\s+/i, ''), 'base64').toString('utf-8'); const [username, password] = credentials.split(':'); const userId = await this.authManager.authenticate({ username, password }); if (userId) { // Create temporary token for this request token = { type: 'composer', userId, scopes: ['composer:*:*:read'], readonly: true, }; } } } // Root packages.json if (path === '/packages.json' || path === '' || path === '/') { return this.handlePackagesJson(); } // Package metadata: /p2/{vendor}/{package}.json or /p2/{vendor}/{package}~dev.json const metadataMatch = path.match(/^\/p2\/([^\/]+\/[^\/]+?)(~dev)?\.json$/); if (metadataMatch) { const [, vendorPackage, devSuffix] = metadataMatch; const includeDev = !!devSuffix; return this.handlePackageMetadata(vendorPackage, includeDev, token); } // Package list: /packages/list.json?filter=vendor/* if (path.startsWith('/packages/list.json')) { const filter = context.query['filter']; return this.handlePackageList(filter, token); } // Package ZIP download: /dists/{vendor}/{package}/{reference}.zip const distMatch = path.match(/^\/dists\/([^\/]+\/[^\/]+)\/([^\/]+)\.zip$/); if (distMatch) { const [, vendorPackage, reference] = distMatch; return this.handlePackageDownload(vendorPackage, reference, token); } // Package upload: PUT /packages/{vendor}/{package} const uploadMatch = path.match(/^\/packages\/([^\/]+\/[^\/]+)$/); if (uploadMatch && context.method === 'PUT') { const vendorPackage = uploadMatch[1]; return this.handlePackageUpload(vendorPackage, context.body, token); } // Package delete: DELETE /packages/{vendor}/{package} if (uploadMatch && context.method === 'DELETE') { const vendorPackage = uploadMatch[1]; return this.handlePackageDelete(vendorPackage, token); } // Version delete: DELETE /packages/{vendor}/{package}/{version} const versionDeleteMatch = path.match(/^\/packages\/([^\/]+\/[^\/]+)\/(.+)$/); if (versionDeleteMatch && context.method === 'DELETE') { const [, vendorPackage, version] = versionDeleteMatch; return this.handleVersionDelete(vendorPackage, version, token); } return { status: 404, headers: { 'Content-Type': 'application/json' }, body: { status: 'error', message: 'Not found' }, }; } protected async checkPermission( token: IAuthToken | null, resource: string, action: string ): Promise { if (!token) return false; return this.authManager.authorize(token, `composer:package:${resource}`, action); } // ======================================================================== // REQUEST HANDLERS // ======================================================================== private async handlePackagesJson(): Promise { const availablePackages = await this.storage.listComposerPackages(); const packagesJson = generatePackagesJson(this.registryUrl, availablePackages); return { status: 200, headers: { 'Content-Type': 'application/json' }, body: packagesJson, }; } private async handlePackageMetadata( vendorPackage: string, includeDev: boolean, token: IAuthToken | null ): Promise { // Check read permission if (!await this.checkPermission(token, vendorPackage, 'read')) { return { status: 401, headers: { 'WWW-Authenticate': 'Bearer realm="composer"' }, body: { status: 'error', message: 'Authentication required' }, }; } const metadata = await this.storage.getComposerPackageMetadata(vendorPackage); if (!metadata) { return { status: 404, headers: { 'Content-Type': 'application/json' }, body: { status: 'error', message: 'Package not found' }, }; } // Filter dev versions if needed let packages = metadata.packages[vendorPackage] || []; if (!includeDev) { packages = packages.filter((pkg: IComposerPackage) => !pkg.version.includes('dev') && !pkg.version.includes('alpha') && !pkg.version.includes('beta') ); } const response: IComposerPackageMetadata = { minified: 'composer/2.0', packages: { [vendorPackage]: packages, }, }; return { status: 200, headers: { 'Content-Type': 'application/json', 'Last-Modified': metadata.lastModified || new Date().toUTCString(), }, body: response, }; } private async handlePackageList( filter: string | undefined, token: IAuthToken | null ): Promise { let packages = await this.storage.listComposerPackages(); // Apply filter if provided if (filter) { const regex = new RegExp('^' + filter.replace(/\*/g, '.*') + '$'); packages = packages.filter(pkg => regex.test(pkg)); } return { status: 200, headers: { 'Content-Type': 'application/json' }, body: { packageNames: packages }, }; } private async handlePackageDownload( vendorPackage: string, reference: string, token: IAuthToken | null ): Promise { // Check read permission if (!await this.checkPermission(token, vendorPackage, 'read')) { return { status: 401, headers: { 'WWW-Authenticate': 'Bearer realm="composer"' }, body: { status: 'error', message: 'Authentication required' }, }; } const zipData = await this.storage.getComposerPackageZip(vendorPackage, reference); if (!zipData) { return { status: 404, headers: {}, body: { status: 'error', message: 'Package file not found' }, }; } return { status: 200, headers: { 'Content-Type': 'application/zip', 'Content-Length': zipData.length.toString(), 'Content-Disposition': `attachment; filename="${reference}.zip"`, }, body: zipData, }; } private async handlePackageUpload( vendorPackage: string, body: any, token: IAuthToken | null ): Promise { // Check write permission if (!await this.checkPermission(token, vendorPackage, 'write')) { return { status: 401, headers: {}, body: { status: 'error', message: 'Write permission required' }, }; } if (!body || !Buffer.isBuffer(body)) { return { status: 400, headers: {}, body: { status: 'error', message: 'ZIP file required' }, }; } // Extract and validate composer.json from ZIP const composerJson = await extractComposerJsonFromZip(body); if (!composerJson || !validateComposerJson(composerJson)) { return { status: 400, headers: {}, body: { status: 'error', message: 'Invalid composer.json in ZIP' }, }; } // Verify package name matches if (composerJson.name !== vendorPackage) { return { status: 400, headers: {}, body: { status: 'error', message: 'Package name mismatch' }, }; } const version = composerJson.version; if (!version) { return { status: 400, headers: {}, body: { status: 'error', message: 'Version required in composer.json' }, }; } // Calculate SHA-1 hash const shasum = await calculateSha1(body); // Generate reference (use version or commit hash) const reference = composerJson.source?.reference || version.replace(/[^a-zA-Z0-9.-]/g, '-'); // Store ZIP file await this.storage.putComposerPackageZip(vendorPackage, reference, body); // Get or create metadata let metadata = await this.storage.getComposerPackageMetadata(vendorPackage); if (!metadata) { metadata = { packages: { [vendorPackage]: [], }, lastModified: new Date().toUTCString(), }; } // Build package entry const packageEntry: IComposerPackage = { ...composerJson, version_normalized: normalizeVersion(version), dist: { type: 'zip', url: `${this.registryUrl}/dists/${vendorPackage}/${reference}.zip`, reference, shasum, }, time: new Date().toISOString(), }; // Add to metadata (check if version already exists) const packages = metadata.packages[vendorPackage] || []; const existingIndex = packages.findIndex((p: IComposerPackage) => p.version === version); if (existingIndex >= 0) { return { status: 409, headers: {}, body: { status: 'error', message: 'Version already exists' }, }; } packages.push(packageEntry); // Sort by version const sortedVersions = sortVersions(packages.map((p: IComposerPackage) => p.version)); packages.sort((a: IComposerPackage, b: IComposerPackage) => { return sortedVersions.indexOf(a.version) - sortedVersions.indexOf(b.version); }); metadata.packages[vendorPackage] = packages; metadata.lastModified = new Date().toUTCString(); // Store updated metadata await this.storage.putComposerPackageMetadata(vendorPackage, metadata); return { status: 201, headers: {}, body: { status: 'success', message: 'Package uploaded successfully', package: vendorPackage, version, }, }; } private async handlePackageDelete( vendorPackage: string, token: IAuthToken | null ): Promise { // Check delete permission if (!await this.checkPermission(token, vendorPackage, 'delete')) { return { status: 401, headers: {}, body: { status: 'error', message: 'Delete permission required' }, }; } const metadata = await this.storage.getComposerPackageMetadata(vendorPackage); if (!metadata) { return { status: 404, headers: {}, body: { status: 'error', message: 'Package not found' }, }; } // Delete all ZIP files const packages = metadata.packages[vendorPackage] || []; for (const pkg of packages) { if (pkg.dist?.reference) { await this.storage.deleteComposerPackageZip(vendorPackage, pkg.dist.reference); } } // Delete metadata await this.storage.deleteComposerPackageMetadata(vendorPackage); return { status: 204, headers: {}, body: null, }; } private async handleVersionDelete( vendorPackage: string, version: string, token: IAuthToken | null ): Promise { // Check delete permission if (!await this.checkPermission(token, vendorPackage, 'delete')) { return { status: 401, headers: {}, body: { status: 'error', message: 'Delete permission required' }, }; } const metadata = await this.storage.getComposerPackageMetadata(vendorPackage); if (!metadata) { return { status: 404, headers: {}, body: { status: 'error', message: 'Package not found' }, }; } const packages = metadata.packages[vendorPackage] || []; const versionIndex = packages.findIndex((p: IComposerPackage) => p.version === version); if (versionIndex === -1) { return { status: 404, headers: {}, body: { status: 'error', message: 'Version not found' }, }; } // Delete ZIP file const pkg = packages[versionIndex]; if (pkg.dist?.reference) { await this.storage.deleteComposerPackageZip(vendorPackage, pkg.dist.reference); } // Remove from metadata packages.splice(versionIndex, 1); metadata.packages[vendorPackage] = packages; metadata.lastModified = new Date().toUTCString(); // Save updated metadata await this.storage.putComposerPackageMetadata(vendorPackage, metadata); return { status: 204, headers: {}, body: null, }; } }