581 lines
17 KiB
TypeScript
581 lines
17 KiB
TypeScript
/**
|
|
* 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, 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<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':
|
|
// 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<IResponse> {
|
|
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<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,
|
|
fullPath: string
|
|
): Promise<IResponse> {
|
|
// 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<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'));
|
|
|
|
// 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<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';
|
|
}
|
|
}
|