feat(maven): Add Maven registry protocol support (storage, auth, routing, interfaces, and exports)
This commit is contained in:
596
ts/maven/classes.mavenregistry.ts
Normal file
596
ts/maven/classes.mavenregistry.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user