267 lines
7.1 KiB
TypeScript
267 lines
7.1 KiB
TypeScript
/**
|
|
* IStorageHooks implementation for smartregistry
|
|
* Integrates Stack.Gallery's storage with smartregistry
|
|
*/
|
|
|
|
import * as plugins from '../plugins.ts';
|
|
import type { TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
|
import { Package } from '../models/package.ts';
|
|
import { Organization } from '../models/organization.ts';
|
|
import { AuditService } from '../services/audit.service.ts';
|
|
|
|
export interface IStorageProviderConfig {
|
|
bucket: plugins.smartbucket.SmartBucket;
|
|
bucketName: string;
|
|
basePath: string;
|
|
}
|
|
|
|
/**
|
|
* Storage hooks implementation that tracks packages in MongoDB
|
|
* and stores artifacts in S3 via smartbucket
|
|
*/
|
|
export class StackGalleryStorageHooks implements plugins.smartregistry.IStorageHooks {
|
|
private config: IStorageProviderConfig;
|
|
|
|
constructor(config: IStorageProviderConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Called before a package is stored
|
|
*/
|
|
public async beforePut(
|
|
context: plugins.smartregistry.IStorageHookContext
|
|
): Promise<plugins.smartregistry.IBeforePutResult> {
|
|
// Validate organization exists and has quota
|
|
const orgId = context.actor?.orgId;
|
|
if (orgId) {
|
|
const org = await Organization.findById(orgId);
|
|
if (!org) {
|
|
return { allowed: false, reason: `Organization not found: ${orgId}` };
|
|
}
|
|
|
|
// Check storage quota
|
|
const newSize = context.metadata?.size || 0;
|
|
if (!org.hasStorageAvailable(newSize)) {
|
|
return { allowed: false, reason: 'Organization storage quota exceeded' };
|
|
}
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
/**
|
|
* Called after a package is successfully stored
|
|
*/
|
|
public async afterPut(
|
|
context: plugins.smartregistry.IStorageHookContext
|
|
): Promise<void> {
|
|
const protocol = context.protocol as TRegistryProtocol;
|
|
const packageName = context.metadata?.packageName || context.key;
|
|
const version = context.metadata?.version || 'unknown';
|
|
const orgId = context.actor?.orgId || '';
|
|
|
|
const packageId = Package.generateId(protocol, orgId, packageName);
|
|
|
|
// Get or create package record
|
|
let pkg = await Package.findById(packageId);
|
|
if (!pkg) {
|
|
pkg = new Package();
|
|
pkg.id = packageId;
|
|
pkg.organizationId = orgId;
|
|
pkg.protocol = protocol;
|
|
pkg.name = packageName;
|
|
pkg.createdById = context.actor?.userId || '';
|
|
pkg.createdAt = new Date();
|
|
}
|
|
|
|
// Add version
|
|
pkg.addVersion({
|
|
version,
|
|
publishedAt: new Date(),
|
|
publishedById: context.actor?.userId || '',
|
|
size: context.metadata?.size || 0,
|
|
digest: context.metadata?.digest,
|
|
downloads: 0,
|
|
metadata: {},
|
|
});
|
|
|
|
// Set latest tag if not set
|
|
if (!pkg.distTags['latest']) {
|
|
pkg.distTags['latest'] = version;
|
|
}
|
|
|
|
await pkg.save();
|
|
|
|
// Update organization storage usage
|
|
if (orgId) {
|
|
const org = await Organization.findById(orgId);
|
|
if (org) {
|
|
await org.updateStorageUsage(context.metadata?.size || 0);
|
|
}
|
|
}
|
|
|
|
// Audit log
|
|
if (context.actor?.userId) {
|
|
await AuditService.withContext({
|
|
actorId: context.actor.userId,
|
|
actorType: 'user',
|
|
organizationId: orgId,
|
|
}).logPackagePublished(packageId, packageName, version, orgId, '');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called after a package is fetched
|
|
*/
|
|
public async afterGet(
|
|
context: plugins.smartregistry.IStorageHookContext
|
|
): Promise<void> {
|
|
const protocol = context.protocol as TRegistryProtocol;
|
|
const packageName = context.metadata?.packageName || context.key;
|
|
const version = context.metadata?.version;
|
|
const orgId = context.actor?.orgId || '';
|
|
|
|
const packageId = Package.generateId(protocol, orgId, packageName);
|
|
|
|
const pkg = await Package.findById(packageId);
|
|
if (pkg) {
|
|
await pkg.incrementDownloads(version);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called before a package is deleted
|
|
*/
|
|
public async beforeDelete(
|
|
context: plugins.smartregistry.IStorageHookContext
|
|
): Promise<plugins.smartregistry.IBeforeDeleteResult> {
|
|
return { allowed: true };
|
|
}
|
|
|
|
/**
|
|
* Called after a package is deleted
|
|
*/
|
|
public async afterDelete(
|
|
context: plugins.smartregistry.IStorageHookContext
|
|
): Promise<void> {
|
|
const protocol = context.protocol as TRegistryProtocol;
|
|
const packageName = context.metadata?.packageName || context.key;
|
|
const version = context.metadata?.version;
|
|
const orgId = context.actor?.orgId || '';
|
|
|
|
const packageId = Package.generateId(protocol, orgId, packageName);
|
|
|
|
const pkg = await Package.findById(packageId);
|
|
if (!pkg) return;
|
|
|
|
if (version) {
|
|
const versionData = pkg.versions[version];
|
|
if (versionData) {
|
|
const sizeReduction = versionData.size;
|
|
delete pkg.versions[version];
|
|
pkg.storageBytes -= sizeReduction;
|
|
|
|
for (const [tag, ver] of Object.entries(pkg.distTags)) {
|
|
if (ver === version) {
|
|
delete pkg.distTags[tag];
|
|
}
|
|
}
|
|
|
|
if (Object.keys(pkg.versions).length === 0) {
|
|
await pkg.delete();
|
|
} else {
|
|
await pkg.save();
|
|
}
|
|
|
|
if (orgId) {
|
|
const org = await Organization.findById(orgId);
|
|
if (org) {
|
|
await org.updateStorageUsage(-sizeReduction);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
const sizeReduction = pkg.storageBytes;
|
|
await pkg.delete();
|
|
|
|
if (orgId) {
|
|
const org = await Organization.findById(orgId);
|
|
if (org) {
|
|
await org.updateStorageUsage(-sizeReduction);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Audit log
|
|
if (context.actor?.userId) {
|
|
await AuditService.withContext({
|
|
actorId: context.actor.userId,
|
|
actorType: 'user',
|
|
organizationId: orgId,
|
|
}).log('PACKAGE_DELETED', 'package', {
|
|
resourceId: packageId,
|
|
resourceName: packageName,
|
|
metadata: { version },
|
|
success: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the S3 path for a package artifact
|
|
*/
|
|
public getArtifactPath(
|
|
protocol: string,
|
|
organizationName: string,
|
|
packageName: string,
|
|
version: string,
|
|
filename: string
|
|
): string {
|
|
return `${this.config.basePath}/${protocol}/${organizationName}/${packageName}/${version}/${filename}`;
|
|
}
|
|
|
|
/**
|
|
* Store artifact in S3
|
|
*/
|
|
public async storeArtifact(
|
|
path: string,
|
|
data: Uint8Array,
|
|
contentType?: string
|
|
): Promise<string> {
|
|
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
|
await bucket.fastPut({
|
|
path,
|
|
contents: data as unknown as string,
|
|
});
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Fetch artifact from S3
|
|
*/
|
|
public async fetchArtifact(path: string): Promise<Uint8Array | null> {
|
|
try {
|
|
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
|
const file = await bucket.fastGet({ path });
|
|
if (!file) return null;
|
|
return new Uint8Array(file);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete artifact from S3
|
|
*/
|
|
public async deleteArtifact(path: string): Promise<boolean> {
|
|
try {
|
|
const bucket = await this.config.bucket.getBucketByName(this.config.bucketName);
|
|
await bucket.fastRemove({ path });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|