/** * Maven Registry Implementation * Implements Maven repository protocol for Java artifacts */ 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 { IMavenCoordinate, IMavenMetadata, IChecksums } from './interfaces.maven.js'; import { pathToGAV, buildFilename, calculateChecksums, generateMetadataXml, parseMetadataXml, formatMavenTimestamp, isSnapshot, validatePom, extractGAVFromPom, gavToPath, } from './helpers.maven.js'; /** * Maven Registry class * Handles Maven repository HTTP protocol */ export class MavenRegistry extends BaseRegistry { private storage: RegistryStorage; private authManager: AuthManager; private basePath: string = '/maven'; private registryUrl: string; constructor( storage: RegistryStorage, authManager: AuthManager, basePath: string, registryUrl: string ) { super(); this.storage = storage; this.authManager = authManager; this.basePath = basePath; this.registryUrl = registryUrl; } public async init(): Promise { // No special initialization needed for Maven } public getBasePath(): string { return this.basePath; } public async handleRequest(context: IRequestContext): Promise { // Remove base path from URL 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) { const tokenString = authHeader.replace(/^(Bearer|Basic)\s+/i, ''); // For now, try to validate as Maven token (reuse npm token type) token = await this.authManager.validateToken(tokenString, 'maven'); } // Parse path to determine request type const coordinate = pathToGAV(path); if (!coordinate) { // Not a valid artifact path, could be metadata or root if (path.endsWith('/maven-metadata.xml')) { return this.handleMetadataRequest(context.method, path, token); } return { status: 404, headers: { 'Content-Type': 'application/json' }, body: { error: 'NOT_FOUND', message: 'Invalid Maven path' }, }; } // Check if it's a checksum file if (coordinate.extension === 'md5' || coordinate.extension === 'sha1' || coordinate.extension === 'sha256' || coordinate.extension === 'sha512') { return this.handleChecksumRequest(context.method, coordinate, token, path); } // Handle artifact requests (JAR, POM, WAR, etc.) return this.handleArtifactRequest(context.method, coordinate, token, context.body); } protected async checkPermission( token: IAuthToken | null, resource: string, action: string ): Promise { if (!token) return false; return this.authManager.authorize(token, `maven:artifact:${resource}`, action); } // ======================================================================== // REQUEST HANDLERS // ======================================================================== private async handleArtifactRequest( method: string, coordinate: IMavenCoordinate, token: IAuthToken | null, body?: Buffer | any ): Promise { const { groupId, artifactId, version } = coordinate; const filename = buildFilename(coordinate); const resource = `${groupId}:${artifactId}`; switch (method) { case 'GET': case 'HEAD': // Maven repositories typically allow anonymous reads return method === 'GET' ? this.getArtifact(groupId, artifactId, version, filename) : this.headArtifact(groupId, artifactId, version, filename); case 'PUT': // Write permission required if (!await this.checkPermission(token, resource, 'write')) { return { status: 401, headers: { 'WWW-Authenticate': `Bearer realm="${this.basePath}",service="maven-registry"`, }, body: { error: 'UNAUTHORIZED', message: 'Write permission required' }, }; } if (!body) { return { status: 400, headers: {}, body: { error: 'BAD_REQUEST', message: 'Request body required' }, }; } return this.putArtifact(groupId, artifactId, version, filename, coordinate, body); case 'DELETE': // Delete permission required if (!await this.checkPermission(token, resource, 'delete')) { return { status: 401, headers: { 'WWW-Authenticate': `Bearer realm="${this.basePath}",service="maven-registry"`, }, body: { error: 'UNAUTHORIZED', message: 'Delete permission required' }, }; } return this.deleteArtifact(groupId, artifactId, version, filename); default: return { status: 405, headers: { 'Allow': 'GET, HEAD, PUT, DELETE' }, body: { error: 'METHOD_NOT_ALLOWED', message: 'Method not allowed' }, }; } } private async handleChecksumRequest( method: string, coordinate: IMavenCoordinate, token: IAuthToken | null, path: string ): Promise { const { groupId, artifactId, version, extension } = coordinate; const resource = `${groupId}:${artifactId}`; // Checksums follow the same permissions as their artifacts (public read) if (method === 'GET' || method === 'HEAD') { return this.getChecksum(groupId, artifactId, version, coordinate, path); } return { status: 405, headers: { 'Allow': 'GET, HEAD' }, body: { error: 'METHOD_NOT_ALLOWED', message: 'Checksums are auto-generated' }, }; } private async handleMetadataRequest( method: string, path: string, token: IAuthToken | null ): Promise { // Parse path to extract groupId and artifactId // Path format: /com/example/my-lib/maven-metadata.xml const parts = path.split('/').filter(p => p && p !== 'maven-metadata.xml'); if (parts.length < 2) { return { status: 400, headers: {}, body: { error: 'BAD_REQUEST', message: 'Invalid metadata path' }, }; } const artifactId = parts[parts.length - 1]; const groupId = parts.slice(0, -1).join('.'); const resource = `${groupId}:${artifactId}`; if (method === 'GET') { // Metadata is usually public (read permission optional) // Some registries allow anonymous metadata access return this.getMetadata(groupId, artifactId); } return { status: 405, headers: { 'Allow': 'GET' }, body: { error: 'METHOD_NOT_ALLOWED', message: 'Metadata is auto-generated' }, }; } // ======================================================================== // ARTIFACT OPERATIONS // ======================================================================== private async getArtifact( groupId: string, artifactId: string, version: string, filename: string ): Promise { const data = await this.storage.getMavenArtifact(groupId, artifactId, version, filename); if (!data) { return { status: 404, headers: {}, body: { error: 'NOT_FOUND', message: 'Artifact not found' }, }; } // Determine content type based on extension const extension = filename.split('.').pop() || ''; const contentType = this.getContentType(extension); return { status: 200, headers: { 'Content-Type': contentType, 'Content-Length': data.length.toString(), }, body: data, }; } private async headArtifact( groupId: string, artifactId: string, version: string, filename: string ): Promise { const exists = await this.storage.mavenArtifactExists(groupId, artifactId, version, filename); if (!exists) { return { status: 404, headers: {}, body: null, }; } // Get file size for Content-Length header const data = await this.storage.getMavenArtifact(groupId, artifactId, version, filename); const extension = filename.split('.').pop() || ''; const contentType = this.getContentType(extension); return { status: 200, headers: { 'Content-Type': contentType, 'Content-Length': data ? data.length.toString() : '0', }, body: null, }; } private async putArtifact( groupId: string, artifactId: string, version: string, filename: string, coordinate: IMavenCoordinate, body: Buffer | any ): Promise { const data = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body)); // Validate POM if uploading .pom file if (coordinate.extension === 'pom') { const pomValid = validatePom(data.toString('utf-8')); if (!pomValid) { return { status: 400, headers: {}, body: { error: 'INVALID_POM', message: 'Invalid POM file' }, }; } // Verify GAV matches path const pomGAV = extractGAVFromPom(data.toString('utf-8')); if (pomGAV && (pomGAV.groupId !== groupId || pomGAV.artifactId !== artifactId || pomGAV.version !== version)) { return { status: 400, headers: {}, body: { error: 'GAV_MISMATCH', message: 'POM coordinates do not match upload path' }, }; } } // Store the artifact await this.storage.putMavenArtifact(groupId, artifactId, version, filename, data); // Generate and store checksums const checksums = await calculateChecksums(data); await this.storeChecksums(groupId, artifactId, version, filename, checksums); // Update maven-metadata.xml if this is a primary artifact (jar, pom, war) if (['jar', 'pom', 'war', 'ear', 'aar'].includes(coordinate.extension)) { await this.updateMetadata(groupId, artifactId, version); } return { status: 201, headers: { 'Location': `${this.registryUrl}/${gavToPath(groupId, artifactId, version)}/${filename}`, }, body: { success: true, message: 'Artifact uploaded successfully' }, }; } private async deleteArtifact( groupId: string, artifactId: string, version: string, filename: string ): Promise { const exists = await this.storage.mavenArtifactExists(groupId, artifactId, version, filename); if (!exists) { return { status: 404, headers: {}, body: { error: 'NOT_FOUND', message: 'Artifact not found' }, }; } await this.storage.deleteMavenArtifact(groupId, artifactId, version, filename); // Also delete checksums for (const ext of ['md5', 'sha1', 'sha256', 'sha512']) { const checksumFile = `${filename}.${ext}`; const checksumExists = await this.storage.mavenArtifactExists(groupId, artifactId, version, checksumFile); if (checksumExists) { await this.storage.deleteMavenArtifact(groupId, artifactId, version, checksumFile); } } return { status: 204, headers: {}, body: null, }; } // ======================================================================== // CHECKSUM OPERATIONS // ======================================================================== private async getChecksum( groupId: string, artifactId: string, version: string, coordinate: IMavenCoordinate, fullPath: string ): Promise { // Extract the filename from the full path (last component) // The fullPath might be something like /com/example/test/test-artifact/1.0.0/test-artifact-1.0.0.jar.md5 const pathParts = fullPath.split('/'); const checksumFilename = pathParts[pathParts.length - 1]; const data = await this.storage.getMavenArtifact(groupId, artifactId, version, checksumFilename); if (!data) { return { status: 404, headers: {}, body: { error: 'NOT_FOUND', message: 'Checksum not found' }, }; } return { status: 200, headers: { 'Content-Type': 'text/plain', 'Content-Length': data.length.toString(), }, body: data, }; } private async storeChecksums( groupId: string, artifactId: string, version: string, filename: string, checksums: IChecksums ): Promise { // Store each checksum as a separate file await this.storage.putMavenArtifact( groupId, artifactId, version, `${filename}.md5`, Buffer.from(checksums.md5, 'utf-8') ); await this.storage.putMavenArtifact( groupId, artifactId, version, `${filename}.sha1`, Buffer.from(checksums.sha1, 'utf-8') ); if (checksums.sha256) { await this.storage.putMavenArtifact( groupId, artifactId, version, `${filename}.sha256`, Buffer.from(checksums.sha256, 'utf-8') ); } if (checksums.sha512) { await this.storage.putMavenArtifact( groupId, artifactId, version, `${filename}.sha512`, Buffer.from(checksums.sha512, 'utf-8') ); } } // ======================================================================== // METADATA OPERATIONS // ======================================================================== private async getMetadata(groupId: string, artifactId: string): Promise { const metadataBuffer = await this.storage.getMavenMetadata(groupId, artifactId); if (!metadataBuffer) { // Generate empty metadata if none exists const emptyMetadata: IMavenMetadata = { groupId, artifactId, versioning: { versions: [], lastUpdated: formatMavenTimestamp(new Date()), }, }; const xml = generateMetadataXml(emptyMetadata); return { status: 200, headers: { 'Content-Type': 'application/xml', 'Content-Length': xml.length.toString(), }, body: Buffer.from(xml, 'utf-8'), }; } return { status: 200, headers: { 'Content-Type': 'application/xml', 'Content-Length': metadataBuffer.length.toString(), }, body: metadataBuffer, }; } private async updateMetadata( groupId: string, artifactId: string, newVersion: string ): Promise { // Get existing metadata or create new const existingBuffer = await this.storage.getMavenMetadata(groupId, artifactId); let metadata: IMavenMetadata; if (existingBuffer) { const parsed = parseMetadataXml(existingBuffer.toString('utf-8')); if (parsed) { metadata = parsed; } else { // Create new if parsing failed metadata = { groupId, artifactId, versioning: { versions: [], lastUpdated: formatMavenTimestamp(new Date()), }, }; } } else { metadata = { groupId, artifactId, versioning: { versions: [], lastUpdated: formatMavenTimestamp(new Date()), }, }; } // Add new version if not already present if (!metadata.versioning.versions.includes(newVersion)) { metadata.versioning.versions.push(newVersion); metadata.versioning.versions.sort(); // Sort versions } // Update latest and release const versions = metadata.versioning.versions; metadata.versioning.latest = versions[versions.length - 1]; // Release is the latest non-SNAPSHOT version const releaseVersions = versions.filter(v => !isSnapshot(v)); if (releaseVersions.length > 0) { metadata.versioning.release = releaseVersions[releaseVersions.length - 1]; } // Update timestamp metadata.versioning.lastUpdated = formatMavenTimestamp(new Date()); // Generate and store XML const xml = generateMetadataXml(metadata); await this.storage.putMavenMetadata(groupId, artifactId, Buffer.from(xml, 'utf-8')); // Note: Checksums for maven-metadata.xml are optional and not critical // They would need special handling since metadata uses a different storage path } // ======================================================================== // UTILITY METHODS // ======================================================================== private getContentType(extension: string): string { const contentTypes: Record = { 'jar': 'application/java-archive', 'war': 'application/java-archive', 'ear': 'application/java-archive', 'aar': 'application/java-archive', 'pom': 'application/xml', 'xml': 'application/xml', 'md5': 'text/plain', 'sha1': 'text/plain', 'sha256': 'text/plain', 'sha512': 'text/plain', }; return contentTypes[extension] || 'application/octet-stream'; } }