feat(maven): Add Maven registry protocol support (storage, auth, routing, interfaces, and exports)

This commit is contained in:
2025-11-21 08:58:29 +00:00
parent 29dea2e0e8
commit 0b31219b7d
16 changed files with 2533 additions and 22 deletions

View File

@@ -0,0 +1,596 @@
/**
* 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<void> {
// No special initialization needed for Maven
}
public getBasePath(): string {
return this.basePath;
}
public async handleRequest(context: IRequestContext): Promise<IResponse> {
// 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);
}
// 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<boolean> {
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<IResponse> {
const { groupId, artifactId, version } = coordinate;
const filename = buildFilename(coordinate);
const resource = `${groupId}:${artifactId}`;
switch (method) {
case 'GET':
case 'HEAD':
// Read permission required
if (!await this.checkPermission(token, resource, 'read')) {
return {
status: 401,
headers: {
'WWW-Authenticate': `Bearer realm="${this.basePath}",service="maven-registry"`,
},
body: { error: 'UNAUTHORIZED', message: 'Authentication required' },
};
}
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
): Promise<IResponse> {
const { groupId, artifactId, version, extension } = coordinate;
const resource = `${groupId}:${artifactId}`;
// Checksums follow the same permissions as their artifacts
if (method === 'GET' || method === 'HEAD') {
if (!await this.checkPermission(token, resource, 'read')) {
return {
status: 401,
headers: {
'WWW-Authenticate': `Bearer realm="${this.basePath}",service="maven-registry"`,
},
body: { error: 'UNAUTHORIZED', message: 'Authentication required' },
};
}
return this.getChecksum(groupId, artifactId, version, coordinate);
}
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<IResponse> {
// 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<IResponse> {
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<IResponse> {
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<IResponse> {
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<IResponse> {
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
): Promise<IResponse> {
const checksumFilename = buildFilename(coordinate);
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<void> {
// 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<IResponse> {
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<void> {
// 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'));
// Also store checksums for metadata
const checksums = await calculateChecksums(Buffer.from(xml, 'utf-8'));
const metadataFilename = 'maven-metadata.xml';
await this.storeChecksums(groupId, artifactId, '', metadataFilename, checksums);
}
// ========================================================================
// UTILITY METHODS
// ========================================================================
private getContentType(extension: string): string {
const contentTypes: Record<string, string> = {
'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';
}
}