506 lines
15 KiB
TypeScript
506 lines
15 KiB
TypeScript
/**
|
|
* StackGalleryRegistry - Main registry class
|
|
* Integrates smartregistry with Stack.Gallery's auth, storage, and database
|
|
*/
|
|
|
|
import * as plugins from './plugins.ts';
|
|
import { closeDb, initDb, isDbConnected } from './models/db.ts';
|
|
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
|
|
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
|
|
import { OpsServer } from './opsserver/classes.opsserver.ts';
|
|
|
|
// Bundled UI files (generated by tsbundle with base64ts output mode)
|
|
let bundledFileMap: Map<string, { data: Uint8Array; contentType: string }> | null = null;
|
|
try {
|
|
// @ts-ignore - generated file may not exist yet
|
|
const { files } = await import('../ts_bundled/bundle.ts');
|
|
bundledFileMap = new Map();
|
|
for (const file of files as Array<{ path: string; contentBase64: string }>) {
|
|
const binary = Uint8Array.from(atob(file.contentBase64), (c) => c.charCodeAt(0));
|
|
const ext = file.path.split('.').pop() || '';
|
|
bundledFileMap.set(`/${file.path}`, { data: binary, contentType: getContentType(ext) });
|
|
}
|
|
} catch {
|
|
console.warn('[StackGalleryRegistry] No bundled UI found (ts_bundled/bundle.ts missing)');
|
|
}
|
|
|
|
function getContentType(ext: string): string {
|
|
const types: Record<string, string> = {
|
|
html: 'text/html',
|
|
js: 'application/javascript',
|
|
css: 'text/css',
|
|
json: 'application/json',
|
|
png: 'image/png',
|
|
jpg: 'image/jpeg',
|
|
jpeg: 'image/jpeg',
|
|
gif: 'image/gif',
|
|
svg: 'image/svg+xml',
|
|
ico: 'image/x-icon',
|
|
woff: 'font/woff',
|
|
woff2: 'font/woff2',
|
|
ttf: 'font/ttf',
|
|
eot: 'application/vnd.ms-fontobject',
|
|
map: 'application/json',
|
|
};
|
|
return types[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
export interface IRegistryConfig {
|
|
// MongoDB configuration
|
|
mongoUrl: string;
|
|
mongoDb: string;
|
|
|
|
// S3 configuration
|
|
s3Endpoint: string;
|
|
s3AccessKey: string;
|
|
s3SecretKey: string;
|
|
s3Bucket: string;
|
|
s3Region?: string;
|
|
|
|
// Server configuration
|
|
host?: string;
|
|
port?: number;
|
|
|
|
// Registry settings
|
|
storagePath?: string;
|
|
enableUpstreamCache?: boolean;
|
|
upstreamCacheExpiry?: number; // hours
|
|
|
|
// JWT configuration
|
|
jwtSecret?: string;
|
|
}
|
|
|
|
export class StackGalleryRegistry {
|
|
private config: IRegistryConfig;
|
|
private smartBucket: plugins.smartbucket.SmartBucket | null = null;
|
|
private smartRegistry: plugins.smartregistry.SmartRegistry | null = null;
|
|
private authProvider: StackGalleryAuthProvider | null = null;
|
|
private storageHooks: StackGalleryStorageHooks | null = null;
|
|
private opsServer: OpsServer | null = null;
|
|
private isInitialized = false;
|
|
|
|
constructor(config: IRegistryConfig) {
|
|
this.config = {
|
|
host: '0.0.0.0',
|
|
port: 3000,
|
|
storagePath: 'packages',
|
|
enableUpstreamCache: true,
|
|
upstreamCacheExpiry: 24,
|
|
...config,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Initialize the registry
|
|
*/
|
|
public async init(): Promise<void> {
|
|
if (this.isInitialized) return;
|
|
|
|
console.log('[StackGalleryRegistry] Initializing...');
|
|
|
|
// Initialize MongoDB
|
|
console.log('[StackGalleryRegistry] Connecting to MongoDB...');
|
|
await initDb(this.config.mongoUrl, this.config.mongoDb);
|
|
console.log('[StackGalleryRegistry] MongoDB connected');
|
|
|
|
// Initialize S3/SmartBucket
|
|
console.log('[StackGalleryRegistry] Initializing S3 storage...');
|
|
this.smartBucket = new plugins.smartbucket.SmartBucket({
|
|
accessKey: this.config.s3AccessKey,
|
|
accessSecret: this.config.s3SecretKey,
|
|
endpoint: this.config.s3Endpoint,
|
|
bucketName: this.config.s3Bucket,
|
|
});
|
|
console.log('[StackGalleryRegistry] S3 storage initialized');
|
|
|
|
// Initialize auth provider
|
|
this.authProvider = new StackGalleryAuthProvider();
|
|
|
|
// Initialize storage hooks
|
|
this.storageHooks = new StackGalleryStorageHooks({
|
|
bucket: this.smartBucket,
|
|
bucketName: this.config.s3Bucket,
|
|
basePath: this.config.storagePath!,
|
|
});
|
|
|
|
// Initialize smartregistry
|
|
console.log('[StackGalleryRegistry] Initializing smartregistry...');
|
|
this.smartRegistry = new plugins.smartregistry.SmartRegistry({
|
|
authProvider: this.authProvider,
|
|
storageHooks: this.storageHooks,
|
|
storage: {
|
|
endpoint: this.config.s3Endpoint,
|
|
accessKey: this.config.s3AccessKey,
|
|
accessSecret: this.config.s3SecretKey,
|
|
bucketName: this.config.s3Bucket,
|
|
region: this.config.s3Region,
|
|
},
|
|
auth: {
|
|
jwtSecret: this.config.jwtSecret || 'change-me-in-production',
|
|
tokenStore: 'database',
|
|
npmTokens: { enabled: true },
|
|
ociTokens: {
|
|
enabled: true,
|
|
realm: 'stack.gallery',
|
|
service: 'registry',
|
|
},
|
|
},
|
|
});
|
|
console.log('[StackGalleryRegistry] smartregistry initialized');
|
|
|
|
// Initialize OpsServer (TypedRequest handlers)
|
|
console.log('[StackGalleryRegistry] Initializing OpsServer...');
|
|
this.opsServer = new OpsServer(this);
|
|
await this.opsServer.start();
|
|
console.log('[StackGalleryRegistry] OpsServer initialized');
|
|
|
|
this.isInitialized = true;
|
|
console.log('[StackGalleryRegistry] Initialization complete');
|
|
}
|
|
|
|
/**
|
|
* Start the HTTP server
|
|
*/
|
|
public async start(): Promise<void> {
|
|
if (!this.isInitialized) {
|
|
await this.init();
|
|
}
|
|
|
|
const port = this.config.port!;
|
|
const host = this.config.host!;
|
|
|
|
console.log(`[StackGalleryRegistry] Starting server on ${host}:${port}...`);
|
|
|
|
Deno.serve(
|
|
{ port, hostname: host },
|
|
async (request: Request): Promise<Response> => {
|
|
return await this.handleRequest(request);
|
|
},
|
|
);
|
|
|
|
console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`);
|
|
}
|
|
|
|
/**
|
|
* Handle incoming HTTP request
|
|
*/
|
|
private async handleRequest(request: Request): Promise<Response> {
|
|
const url = new URL(request.url);
|
|
const path = url.pathname;
|
|
|
|
// Health check
|
|
if (path === '/health' || path === '/healthz') {
|
|
return this.healthCheck();
|
|
}
|
|
|
|
// TypedRequest endpoint (handled by OpsServer TypedRouter)
|
|
if (path === '/typedrequest' && request.method === 'POST') {
|
|
return await this.handleTypedRequest(request);
|
|
}
|
|
|
|
// Legacy REST API endpoints (keep for backwards compatibility during migration)
|
|
// TODO: Remove once frontend is fully migrated to TypedRequest
|
|
|
|
// Registry protocol endpoints (handled by smartregistry)
|
|
const registryPaths = [
|
|
'/-/',
|
|
'/v2/',
|
|
'/maven2/',
|
|
'/simple/',
|
|
'/pypi/',
|
|
'/api/v1/crates/',
|
|
'/packages.json',
|
|
'/p/',
|
|
'/api/v1/gems/',
|
|
'/gems/',
|
|
];
|
|
const isRegistryPath = registryPaths.some((p) => path.startsWith(p)) ||
|
|
(path.startsWith('/@') && !path.startsWith('/@stack'));
|
|
|
|
if (this.smartRegistry && isRegistryPath) {
|
|
try {
|
|
// Convert Request to IRequestContext
|
|
const requestContext = await this.requestToContext(request);
|
|
const response = await this.smartRegistry.handleRequest(requestContext);
|
|
if (response) return this.contextResponseToResponse(response);
|
|
} catch (error) {
|
|
console.error('[StackGalleryRegistry] Request error:', error);
|
|
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Serve static UI files
|
|
return this.serveStaticFile(path);
|
|
}
|
|
|
|
/**
|
|
* Convert a Deno Request to smartregistry IRequestContext
|
|
*/
|
|
private async requestToContext(
|
|
request: Request,
|
|
): Promise<plugins.smartregistry.IRequestContext> {
|
|
const url = new URL(request.url);
|
|
const headers: Record<string, string> = {};
|
|
request.headers.forEach((value, key) => {
|
|
headers[key] = value;
|
|
});
|
|
const query: Record<string, string> = {};
|
|
url.searchParams.forEach((value, key) => {
|
|
query[key] = value;
|
|
});
|
|
|
|
let body: unknown = undefined;
|
|
// deno-lint-ignore no-explicit-any
|
|
let rawBody: any = undefined;
|
|
if (request.body && request.method !== 'GET' && request.method !== 'HEAD') {
|
|
try {
|
|
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
rawBody = bytes;
|
|
const contentType = request.headers.get('content-type') || '';
|
|
if (contentType.includes('json')) {
|
|
body = JSON.parse(new TextDecoder().decode(bytes));
|
|
}
|
|
} catch {
|
|
// Body parsing failed, continue with undefined body
|
|
}
|
|
}
|
|
|
|
// Extract token from Authorization header
|
|
let token: string | undefined;
|
|
const authHeader = headers['authorization'];
|
|
if (authHeader?.startsWith('Bearer ')) {
|
|
token = authHeader.substring(7);
|
|
}
|
|
|
|
return {
|
|
method: request.method,
|
|
path: url.pathname,
|
|
headers,
|
|
query,
|
|
body,
|
|
rawBody,
|
|
token,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert smartregistry IResponse to Deno Response
|
|
*/
|
|
private contextResponseToResponse(response: plugins.smartregistry.IResponse): Response {
|
|
const headers = new Headers(response.headers || {});
|
|
let body: BodyInit | null = null;
|
|
|
|
if (response.body !== undefined) {
|
|
if (typeof response.body === 'string') {
|
|
body = response.body;
|
|
} else if (response.body instanceof Uint8Array) {
|
|
body = response.body as unknown as BodyInit;
|
|
} else {
|
|
body = JSON.stringify(response.body);
|
|
if (!headers.has('content-type')) {
|
|
headers.set('content-type', 'application/json');
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Response(body, {
|
|
status: response.status,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Serve static files from bundled UI
|
|
*/
|
|
private serveStaticFile(path: string): Response {
|
|
if (!bundledFileMap) {
|
|
return new Response('UI not bundled. Run tsbundle first.', { status: 404 });
|
|
}
|
|
|
|
const filePath = path === '/' ? '/index.html' : path;
|
|
|
|
// Get bundled file
|
|
const file = bundledFileMap.get(filePath);
|
|
if (file) {
|
|
return new Response(file.data as unknown as BodyInit, {
|
|
status: 200,
|
|
headers: { 'Content-Type': file.contentType },
|
|
});
|
|
}
|
|
|
|
// SPA fallback: serve index.html for unknown paths
|
|
const indexFile = bundledFileMap.get('/index.html');
|
|
if (indexFile) {
|
|
return new Response(indexFile.data as unknown as BodyInit, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/html' },
|
|
});
|
|
}
|
|
|
|
return new Response('Not Found', { status: 404 });
|
|
}
|
|
|
|
/**
|
|
* Handle TypedRequest calls
|
|
*/
|
|
private async handleTypedRequest(request: Request): Promise<Response> {
|
|
if (!this.opsServer) {
|
|
return new Response(JSON.stringify({ error: 'OpsServer not initialized' }), {
|
|
status: 503,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const body = await request.json();
|
|
const result = await this.opsServer.typedrouter.routeAndAddResponse(body);
|
|
return new Response(JSON.stringify(result), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
} catch (error) {
|
|
console.error('[StackGalleryRegistry] TypedRequest error:', error);
|
|
const message = error instanceof Error ? error.message : 'Internal server error';
|
|
return new Response(
|
|
JSON.stringify({ error: message }),
|
|
{
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Health check endpoint
|
|
*/
|
|
private healthCheck(): Response {
|
|
const healthy = this.isInitialized && isDbConnected();
|
|
|
|
const status = {
|
|
status: healthy ? 'healthy' : 'unhealthy',
|
|
timestamp: new Date().toISOString(),
|
|
services: {
|
|
mongodb: isDbConnected() ? 'connected' : 'disconnected',
|
|
s3: this.smartBucket ? 'initialized' : 'not initialized',
|
|
registry: this.smartRegistry ? 'initialized' : 'not initialized',
|
|
},
|
|
};
|
|
|
|
return new Response(JSON.stringify(status), {
|
|
status: healthy ? 200 : 503,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stop the registry
|
|
*/
|
|
public async stop(): Promise<void> {
|
|
console.log('[StackGalleryRegistry] Shutting down...');
|
|
if (this.opsServer) {
|
|
await this.opsServer.stop();
|
|
}
|
|
await closeDb();
|
|
this.isInitialized = false;
|
|
console.log('[StackGalleryRegistry] Shutdown complete');
|
|
}
|
|
|
|
/**
|
|
* Get the smartregistry instance
|
|
*/
|
|
public getSmartRegistry(): plugins.smartregistry.SmartRegistry | null {
|
|
return this.smartRegistry;
|
|
}
|
|
|
|
/**
|
|
* Get the smartbucket instance
|
|
*/
|
|
public getSmartBucket(): plugins.smartbucket.SmartBucket | null {
|
|
return this.smartBucket;
|
|
}
|
|
|
|
/**
|
|
* Check if registry is initialized
|
|
*/
|
|
public getIsInitialized(): boolean {
|
|
return this.isInitialized;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create registry from environment variables
|
|
*/
|
|
export function createRegistryFromEnv(): StackGalleryRegistry {
|
|
const config: IRegistryConfig = {
|
|
mongoUrl: Deno.env.get('MONGODB_URL') || 'mongodb://localhost:27017',
|
|
mongoDb: Deno.env.get('MONGODB_DB') || 'stackgallery',
|
|
s3Endpoint: Deno.env.get('S3_ENDPOINT') || 'http://localhost:9000',
|
|
s3AccessKey: Deno.env.get('S3_ACCESS_KEY') || 'minioadmin',
|
|
s3SecretKey: Deno.env.get('S3_SECRET_KEY') || 'minioadmin',
|
|
s3Bucket: Deno.env.get('S3_BUCKET') || 'registry',
|
|
s3Region: Deno.env.get('S3_REGION'),
|
|
host: Deno.env.get('HOST') || '0.0.0.0',
|
|
port: parseInt(Deno.env.get('PORT') || '3000', 10),
|
|
storagePath: Deno.env.get('STORAGE_PATH') || 'packages',
|
|
enableUpstreamCache: Deno.env.get('ENABLE_UPSTREAM_CACHE') !== 'false',
|
|
upstreamCacheExpiry: parseInt(Deno.env.get('UPSTREAM_CACHE_EXPIRY') || '24', 10),
|
|
jwtSecret: Deno.env.get('JWT_SECRET'),
|
|
};
|
|
|
|
return new StackGalleryRegistry(config);
|
|
}
|
|
|
|
/**
|
|
* Create registry from .nogit/env.json file (for local development)
|
|
* Falls back to environment variables if file doesn't exist
|
|
*/
|
|
export async function createRegistryFromEnvFile(): Promise<StackGalleryRegistry> {
|
|
const envPath = '.nogit/env.json';
|
|
|
|
try {
|
|
const envText = await Deno.readTextFile(envPath);
|
|
const env = JSON.parse(envText);
|
|
|
|
console.log('[StackGalleryRegistry] Loading config from .nogit/env.json');
|
|
|
|
// Build S3 endpoint from host/port/ssl settings
|
|
const s3Protocol = env.S3_USESSL ? 'https' : 'http';
|
|
const s3Endpoint = `${s3Protocol}://${env.S3_HOST || 'localhost'}:${env.S3_PORT || '9000'}`;
|
|
|
|
const config: IRegistryConfig = {
|
|
mongoUrl: env.MONGODB_URL ||
|
|
`mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${
|
|
env.MONGODB_PORT || '27017'
|
|
}/${env.MONGODB_NAME}?authSource=admin`,
|
|
mongoDb: env.MONGODB_NAME || 'stackgallery',
|
|
s3Endpoint: s3Endpoint,
|
|
s3AccessKey: env.S3_ACCESSKEY || env.S3_ACCESS_KEY || 'minioadmin',
|
|
s3SecretKey: env.S3_SECRETKEY || env.S3_SECRET_KEY || 'minioadmin',
|
|
s3Bucket: env.S3_BUCKET || 'registry',
|
|
s3Region: env.S3_REGION,
|
|
host: env.HOST || '0.0.0.0',
|
|
port: parseInt(env.PORT || '3000', 10),
|
|
storagePath: env.STORAGE_PATH || 'packages',
|
|
enableUpstreamCache: env.ENABLE_UPSTREAM_CACHE !== false,
|
|
upstreamCacheExpiry: parseInt(env.UPSTREAM_CACHE_EXPIRY || '24', 10),
|
|
jwtSecret: env.JWT_SECRET,
|
|
};
|
|
|
|
return new StackGalleryRegistry(config);
|
|
} catch (error) {
|
|
if (error instanceof Deno.errors.NotFound) {
|
|
console.log('[StackGalleryRegistry] No .nogit/env.json found, using environment variables');
|
|
} else {
|
|
console.warn(
|
|
'[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:',
|
|
error,
|
|
);
|
|
}
|
|
return createRegistryFromEnv();
|
|
}
|
|
}
|