- Added SettingsComponent for user profile management, including display name and password change functionality. - Introduced TokensComponent for managing API tokens, including creation and revocation. - Created LayoutComponent for consistent application layout with navigation and user information. - Established main application structure in index.html and main.ts. - Integrated Tailwind CSS for styling and responsive design. - Configured TypeScript settings for strict type checking and module resolution.
322 lines
9.1 KiB
TypeScript
322 lines
9.1 KiB
TypeScript
/**
|
|
* Package API handlers
|
|
*/
|
|
|
|
import type { IApiContext, IApiResponse } from '../router.ts';
|
|
import { PermissionService } from '../../services/permission.service.ts';
|
|
import { Package, Repository } from '../../models/index.ts';
|
|
import type { TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
|
|
|
export class PackageApi {
|
|
private permissionService: PermissionService;
|
|
|
|
constructor(permissionService: PermissionService) {
|
|
this.permissionService = permissionService;
|
|
}
|
|
|
|
/**
|
|
* GET /api/v1/packages (search)
|
|
*/
|
|
public async search(ctx: IApiContext): Promise<IApiResponse> {
|
|
try {
|
|
const query = ctx.url.searchParams.get('q') || '';
|
|
const protocol = ctx.url.searchParams.get('protocol') as TRegistryProtocol | undefined;
|
|
const organizationId = ctx.url.searchParams.get('organizationId') || undefined;
|
|
const limit = parseInt(ctx.url.searchParams.get('limit') || '50', 10);
|
|
const offset = parseInt(ctx.url.searchParams.get('offset') || '0', 10);
|
|
|
|
// For authenticated users, search includes private packages they have access to
|
|
// For anonymous users, only search public packages
|
|
const isPrivate = ctx.actor?.userId ? undefined : false;
|
|
|
|
const packages = await Package.search(query, {
|
|
protocol,
|
|
organizationId,
|
|
isPrivate,
|
|
limit,
|
|
offset,
|
|
});
|
|
|
|
// Filter out packages user doesn't have access to
|
|
const accessiblePackages = [];
|
|
for (const pkg of packages) {
|
|
if (!pkg.isPrivate) {
|
|
accessiblePackages.push(pkg);
|
|
continue;
|
|
}
|
|
|
|
if (ctx.actor?.userId) {
|
|
const canAccess = await this.permissionService.canAccessPackage(
|
|
ctx.actor.userId,
|
|
pkg.organizationId,
|
|
pkg.repositoryId,
|
|
'read'
|
|
);
|
|
if (canAccess) {
|
|
accessiblePackages.push(pkg);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
packages: accessiblePackages.map((pkg) => ({
|
|
id: pkg.id,
|
|
name: pkg.name,
|
|
description: pkg.description,
|
|
protocol: pkg.protocol,
|
|
organizationId: pkg.organizationId,
|
|
repositoryId: pkg.repositoryId,
|
|
latestVersion: pkg.distTags['latest'],
|
|
isPrivate: pkg.isPrivate,
|
|
downloadCount: pkg.downloadCount,
|
|
updatedAt: pkg.updatedAt,
|
|
})),
|
|
total: accessiblePackages.length,
|
|
limit,
|
|
offset,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error('[PackageApi] Search error:', error);
|
|
return { status: 500, body: { error: 'Failed to search packages' } };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/v1/packages/:id
|
|
*/
|
|
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
|
const { id } = ctx.params;
|
|
|
|
try {
|
|
const pkg = await Package.findById(decodeURIComponent(id));
|
|
if (!pkg) {
|
|
return { status: 404, body: { error: 'Package not found' } };
|
|
}
|
|
|
|
// Check access
|
|
if (pkg.isPrivate) {
|
|
if (!ctx.actor?.userId) {
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
|
}
|
|
|
|
const canAccess = await this.permissionService.canAccessPackage(
|
|
ctx.actor.userId,
|
|
pkg.organizationId,
|
|
pkg.repositoryId,
|
|
'read'
|
|
);
|
|
|
|
if (!canAccess) {
|
|
return { status: 403, body: { error: 'Access denied' } };
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
id: pkg.id,
|
|
name: pkg.name,
|
|
description: pkg.description,
|
|
protocol: pkg.protocol,
|
|
organizationId: pkg.organizationId,
|
|
repositoryId: pkg.repositoryId,
|
|
distTags: pkg.distTags,
|
|
versions: Object.keys(pkg.versions),
|
|
isPrivate: pkg.isPrivate,
|
|
downloadCount: pkg.downloadCount,
|
|
starCount: pkg.starCount,
|
|
storageBytes: pkg.storageBytes,
|
|
createdAt: pkg.createdAt,
|
|
updatedAt: pkg.updatedAt,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error('[PackageApi] Get error:', error);
|
|
return { status: 500, body: { error: 'Failed to get package' } };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/v1/packages/:id/versions
|
|
*/
|
|
public async listVersions(ctx: IApiContext): Promise<IApiResponse> {
|
|
const { id } = ctx.params;
|
|
|
|
try {
|
|
const pkg = await Package.findById(decodeURIComponent(id));
|
|
if (!pkg) {
|
|
return { status: 404, body: { error: 'Package not found' } };
|
|
}
|
|
|
|
// Check access
|
|
if (pkg.isPrivate) {
|
|
if (!ctx.actor?.userId) {
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
|
}
|
|
|
|
const canAccess = await this.permissionService.canAccessPackage(
|
|
ctx.actor.userId,
|
|
pkg.organizationId,
|
|
pkg.repositoryId,
|
|
'read'
|
|
);
|
|
|
|
if (!canAccess) {
|
|
return { status: 403, body: { error: 'Access denied' } };
|
|
}
|
|
}
|
|
|
|
const versions = Object.entries(pkg.versions).map(([version, data]) => ({
|
|
version,
|
|
publishedAt: data.publishedAt,
|
|
size: data.size,
|
|
downloads: data.downloads,
|
|
checksum: data.checksum,
|
|
}));
|
|
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
packageId: pkg.id,
|
|
packageName: pkg.name,
|
|
distTags: pkg.distTags,
|
|
versions,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error('[PackageApi] List versions error:', error);
|
|
return { status: 500, body: { error: 'Failed to list versions' } };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/v1/packages/:id
|
|
*/
|
|
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
|
if (!ctx.actor?.userId) {
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
|
}
|
|
|
|
const { id } = ctx.params;
|
|
|
|
try {
|
|
const pkg = await Package.findById(decodeURIComponent(id));
|
|
if (!pkg) {
|
|
return { status: 404, body: { error: 'Package not found' } };
|
|
}
|
|
|
|
// Check delete permission
|
|
const canDelete = await this.permissionService.canAccessPackage(
|
|
ctx.actor.userId,
|
|
pkg.organizationId,
|
|
pkg.repositoryId,
|
|
'delete'
|
|
);
|
|
|
|
if (!canDelete) {
|
|
return { status: 403, body: { error: 'Delete permission required' } };
|
|
}
|
|
|
|
// Delete the package
|
|
await pkg.delete();
|
|
|
|
// Update repository package count
|
|
const repo = await Repository.findById(pkg.repositoryId);
|
|
if (repo) {
|
|
repo.packageCount = Math.max(0, repo.packageCount - 1);
|
|
repo.storageBytes -= pkg.storageBytes;
|
|
await repo.save();
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
body: { message: 'Package deleted successfully' },
|
|
};
|
|
} catch (error) {
|
|
console.error('[PackageApi] Delete error:', error);
|
|
return { status: 500, body: { error: 'Failed to delete package' } };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/v1/packages/:id/versions/:version
|
|
*/
|
|
public async deleteVersion(ctx: IApiContext): Promise<IApiResponse> {
|
|
if (!ctx.actor?.userId) {
|
|
return { status: 401, body: { error: 'Authentication required' } };
|
|
}
|
|
|
|
const { id, version } = ctx.params;
|
|
|
|
try {
|
|
const pkg = await Package.findById(decodeURIComponent(id));
|
|
if (!pkg) {
|
|
return { status: 404, body: { error: 'Package not found' } };
|
|
}
|
|
|
|
const versionData = pkg.versions[version];
|
|
if (!versionData) {
|
|
return { status: 404, body: { error: 'Version not found' } };
|
|
}
|
|
|
|
// Check delete permission
|
|
const canDelete = await this.permissionService.canAccessPackage(
|
|
ctx.actor.userId,
|
|
pkg.organizationId,
|
|
pkg.repositoryId,
|
|
'delete'
|
|
);
|
|
|
|
if (!canDelete) {
|
|
return { status: 403, body: { error: 'Delete permission required' } };
|
|
}
|
|
|
|
// Check if this is the only version
|
|
if (Object.keys(pkg.versions).length === 1) {
|
|
return {
|
|
status: 400,
|
|
body: { error: 'Cannot delete the only version. Delete the entire package instead.' },
|
|
};
|
|
}
|
|
|
|
// Remove version
|
|
const sizeReduction = versionData.size;
|
|
delete pkg.versions[version];
|
|
pkg.storageBytes -= sizeReduction;
|
|
|
|
// Update dist tags
|
|
for (const [tag, tagVersion] of Object.entries(pkg.distTags)) {
|
|
if (tagVersion === version) {
|
|
delete pkg.distTags[tag];
|
|
}
|
|
}
|
|
|
|
// Set new latest if needed
|
|
if (!pkg.distTags['latest'] && Object.keys(pkg.versions).length > 0) {
|
|
const versions = Object.keys(pkg.versions).sort();
|
|
pkg.distTags['latest'] = versions[versions.length - 1];
|
|
}
|
|
|
|
await pkg.save();
|
|
|
|
// Update repository storage
|
|
const repo = await Repository.findById(pkg.repositoryId);
|
|
if (repo) {
|
|
repo.storageBytes -= sizeReduction;
|
|
await repo.save();
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
body: { message: 'Version deleted successfully' },
|
|
};
|
|
} catch (error) {
|
|
console.error('[PackageApi] Delete version error:', error);
|
|
return { status: 500, body: { error: 'Failed to delete version' } };
|
|
}
|
|
}
|
|
}
|