mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-07 21:29:06 +00:00
Initial commit
This commit is contained in:
90
routes/api/registry/catalog/+server.ts
Normal file
90
routes/api/registry/catalog/+server.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getRegistry } from '$lib/server/db';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const registryId = url.searchParams.get('registry');
|
||||
|
||||
if (!registryId) {
|
||||
return json({ error: 'Registry ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const registry = await getRegistry(parseInt(registryId));
|
||||
if (!registry) {
|
||||
return json({ error: 'Registry not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Docker Hub doesn't support catalog listing
|
||||
if (registry.url.includes('docker.io') || registry.url.includes('hub.docker.com') || registry.url.includes('registry.hub.docker.com')) {
|
||||
return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build the catalog URL
|
||||
let catalogUrl = registry.url;
|
||||
if (!catalogUrl.endsWith('/')) {
|
||||
catalogUrl += '/';
|
||||
}
|
||||
catalogUrl += 'v2/_catalog';
|
||||
|
||||
// Prepare headers
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
// Add auth if credentials are present
|
||||
if (registry.username && registry.password) {
|
||||
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
const response = await fetch(catalogUrl, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return json({ error: 'Authentication failed. Please check your credentials.' }, { status: 401 });
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return json({ error: 'Registry does not support V2 catalog API' }, { status: 404 });
|
||||
}
|
||||
return json({ error: `Registry returned error: ${response.status}` }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// The V2 API returns { repositories: [...] }
|
||||
const repositories = data.repositories || [];
|
||||
|
||||
// For each repository, we could fetch tags, but that's expensive
|
||||
// Just return the repository names for now
|
||||
const results = repositories.map((name: string) => ({
|
||||
name,
|
||||
description: '',
|
||||
star_count: 0,
|
||||
is_official: false,
|
||||
is_automated: false
|
||||
}));
|
||||
|
||||
return json(results);
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching registry catalog:', error);
|
||||
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return json({ error: 'Could not connect to registry' }, { status: 503 });
|
||||
}
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return json({ error: 'Registry host not found' }, { status: 503 });
|
||||
}
|
||||
if (error.cause?.code === 'ERR_SSL_PACKET_LENGTH_TOO_LONG') {
|
||||
return json({ error: 'SSL error: Registry may be using HTTP, not HTTPS. Try changing the URL to http://' }, { status: 503 });
|
||||
}
|
||||
if (error.cause?.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || error.cause?.code === 'CERT_HAS_EXPIRED') {
|
||||
return json({ error: 'SSL certificate error. Registry may have an invalid or self-signed certificate.' }, { status: 503 });
|
||||
}
|
||||
|
||||
return json({ error: 'Failed to fetch catalog: ' + (error.message || 'Unknown error') }, { status: 500 });
|
||||
}
|
||||
};
|
||||
109
routes/api/registry/image/+server.ts
Normal file
109
routes/api/registry/image/+server.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getRegistry } from '$lib/server/db';
|
||||
|
||||
function isDockerHub(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return lower.includes('docker.io') ||
|
||||
lower.includes('hub.docker.com') ||
|
||||
lower.includes('registry.hub.docker.com');
|
||||
}
|
||||
|
||||
export const DELETE: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const registryId = url.searchParams.get('registry');
|
||||
const imageName = url.searchParams.get('image');
|
||||
const tag = url.searchParams.get('tag');
|
||||
|
||||
if (!registryId) {
|
||||
return json({ error: 'Registry ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!imageName) {
|
||||
return json({ error: 'Image name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!tag) {
|
||||
return json({ error: 'Tag is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const registry = await getRegistry(parseInt(registryId));
|
||||
if (!registry) {
|
||||
return json({ error: 'Registry not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Docker Hub doesn't support deletion via API
|
||||
if (isDockerHub(registry.url)) {
|
||||
return json({ error: 'Docker Hub does not support image deletion via API. Please use the Docker Hub web interface.' }, { status: 400 });
|
||||
}
|
||||
|
||||
let baseUrl = registry.url;
|
||||
if (!baseUrl.endsWith('/')) {
|
||||
baseUrl += '/';
|
||||
}
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
};
|
||||
|
||||
if (registry.username && registry.password) {
|
||||
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
// Step 1: Get the manifest digest
|
||||
const manifestUrl = `${baseUrl}v2/${imageName}/manifests/${tag}`;
|
||||
const headResponse = await fetch(manifestUrl, {
|
||||
method: 'HEAD',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!headResponse.ok) {
|
||||
if (headResponse.status === 401) {
|
||||
return json({ error: 'Authentication failed' }, { status: 401 });
|
||||
}
|
||||
if (headResponse.status === 404) {
|
||||
return json({ error: 'Image or tag not found' }, { status: 404 });
|
||||
}
|
||||
return json({ error: `Failed to get manifest: ${headResponse.status}` }, { status: headResponse.status });
|
||||
}
|
||||
|
||||
const digest = headResponse.headers.get('Docker-Content-Digest');
|
||||
if (!digest) {
|
||||
return json({ error: 'Could not get image digest. Registry may not support deletion.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Step 2: Delete the manifest by digest
|
||||
const deleteUrl = `${baseUrl}v2/${imageName}/manifests/${digest}`;
|
||||
const deleteResponse = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!deleteResponse.ok) {
|
||||
if (deleteResponse.status === 401) {
|
||||
return json({ error: 'Authentication failed' }, { status: 401 });
|
||||
}
|
||||
if (deleteResponse.status === 404) {
|
||||
return json({ error: 'Manifest not found' }, { status: 404 });
|
||||
}
|
||||
if (deleteResponse.status === 405) {
|
||||
return json({ error: 'Registry does not allow deletion. Enable REGISTRY_STORAGE_DELETE_ENABLED=true on the registry.' }, { status: 405 });
|
||||
}
|
||||
return json({ error: `Failed to delete image: ${deleteResponse.status}` }, { status: deleteResponse.status });
|
||||
}
|
||||
|
||||
return json({ success: true, message: `Deleted ${imageName}:${tag}` });
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting image:', error);
|
||||
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return json({ error: 'Could not connect to registry' }, { status: 503 });
|
||||
}
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return json({ error: 'Registry host not found' }, { status: 503 });
|
||||
}
|
||||
|
||||
return json({ error: error.message || 'Failed to delete image' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
136
routes/api/registry/search/+server.ts
Normal file
136
routes/api/registry/search/+server.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getRegistry } from '$lib/server/db';
|
||||
|
||||
interface SearchResult {
|
||||
name: string;
|
||||
description: string;
|
||||
star_count: number;
|
||||
is_official: boolean;
|
||||
is_automated: boolean;
|
||||
}
|
||||
|
||||
function isDockerHub(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return lower.includes('docker.io') ||
|
||||
lower.includes('hub.docker.com') ||
|
||||
lower.includes('registry.hub.docker.com');
|
||||
}
|
||||
|
||||
async function searchDockerHub(term: string, limit: number): Promise<SearchResult[]> {
|
||||
// Use Docker Hub's search API directly
|
||||
const url = `https://hub.docker.com/v2/search/repositories/?query=${encodeURIComponent(term)}&page_size=${limit}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Docker Hub search failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const results = data.results || [];
|
||||
|
||||
return results.map((item: any) => ({
|
||||
name: item.repo_name || item.name,
|
||||
description: item.short_description || item.description || '',
|
||||
star_count: item.star_count || 0,
|
||||
is_official: item.is_official || false,
|
||||
is_automated: item.is_automated || false
|
||||
}));
|
||||
}
|
||||
|
||||
async function searchPrivateRegistry(registry: any, term: string, limit: number): Promise<SearchResult[]> {
|
||||
// Private registries use the V2 catalog API
|
||||
let baseUrl = registry.url;
|
||||
if (!baseUrl.endsWith('/')) {
|
||||
baseUrl += '/';
|
||||
}
|
||||
|
||||
const catalogUrl = `${baseUrl}v2/_catalog?n=1000`;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
if (registry.username && registry.password) {
|
||||
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
const response = await fetch(catalogUrl, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
throw new Error(`Registry returned error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const repositories = data.repositories || [];
|
||||
|
||||
// Filter repositories by search term (case-insensitive)
|
||||
const termLower = term.toLowerCase();
|
||||
const filtered = repositories
|
||||
.filter((name: string) => name.toLowerCase().includes(termLower))
|
||||
.slice(0, limit);
|
||||
|
||||
// Return results in the same format as Docker Hub
|
||||
return filtered.map((name: string) => ({
|
||||
name,
|
||||
description: '',
|
||||
star_count: 0,
|
||||
is_official: false,
|
||||
is_automated: false
|
||||
}));
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const term = url.searchParams.get('term');
|
||||
const limit = parseInt(url.searchParams.get('limit') || '25', 10);
|
||||
const registryId = url.searchParams.get('registry');
|
||||
|
||||
if (!term) {
|
||||
return json({ error: 'Search term is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
let results: SearchResult[];
|
||||
|
||||
if (!registryId) {
|
||||
// No registry specified, search Docker Hub
|
||||
results = await searchDockerHub(term, limit);
|
||||
} else {
|
||||
const registry = await getRegistry(parseInt(registryId));
|
||||
if (!registry) {
|
||||
return json({ error: 'Registry not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (isDockerHub(registry.url)) {
|
||||
results = await searchDockerHub(term, limit);
|
||||
} else {
|
||||
results = await searchPrivateRegistry(registry, term, limit);
|
||||
}
|
||||
}
|
||||
|
||||
return json(results);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to search images:', error);
|
||||
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return json({ error: 'Could not connect to registry' }, { status: 503 });
|
||||
}
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return json({ error: 'Registry host not found' }, { status: 503 });
|
||||
}
|
||||
|
||||
return json({ error: error.message || 'Failed to search images' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
143
routes/api/registry/tags/+server.ts
Normal file
143
routes/api/registry/tags/+server.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getRegistry } from '$lib/server/db';
|
||||
|
||||
interface TagInfo {
|
||||
name: string;
|
||||
size?: number;
|
||||
lastUpdated?: string;
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
function isDockerHub(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return lower.includes('docker.io') ||
|
||||
lower.includes('hub.docker.com') ||
|
||||
lower.includes('registry.hub.docker.com');
|
||||
}
|
||||
|
||||
async function fetchDockerHubTags(imageName: string): Promise<TagInfo[]> {
|
||||
// Docker Hub uses a different API
|
||||
// For official images: https://hub.docker.com/v2/repositories/library/<image>/tags
|
||||
// For user images: https://hub.docker.com/v2/repositories/<user>/<image>/tags
|
||||
|
||||
let repoPath = imageName;
|
||||
if (!imageName.includes('/')) {
|
||||
// Official image (e.g., nginx -> library/nginx)
|
||||
repoPath = `library/${imageName}`;
|
||||
}
|
||||
|
||||
const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=100&ordering=last_updated`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Image not found on Docker Hub');
|
||||
}
|
||||
throw new Error(`Docker Hub returned error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const results = data.results || [];
|
||||
|
||||
return results.map((tag: any) => ({
|
||||
name: tag.name,
|
||||
size: tag.full_size || tag.images?.[0]?.size,
|
||||
lastUpdated: tag.last_updated || tag.tag_last_pushed,
|
||||
digest: tag.images?.[0]?.digest
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchRegistryTags(registry: any, imageName: string): Promise<TagInfo[]> {
|
||||
// Standard V2 registry API
|
||||
let baseUrl = registry.url;
|
||||
if (!baseUrl.endsWith('/')) {
|
||||
baseUrl += '/';
|
||||
}
|
||||
|
||||
const tagsUrl = `${baseUrl}v2/${imageName}/tags/list`;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
if (registry.username && registry.password) {
|
||||
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
const response = await fetch(tagsUrl, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error('Image not found in registry');
|
||||
}
|
||||
throw new Error(`Registry returned error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const tags = data.tags || [];
|
||||
|
||||
// For V2 registries, we only get tag names, not sizes or dates
|
||||
// We could fetch manifests for each tag to get more info, but that's expensive
|
||||
// Just return the basic info for now
|
||||
return tags.map((name: string) => ({
|
||||
name,
|
||||
size: undefined,
|
||||
lastUpdated: undefined,
|
||||
digest: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const registryId = url.searchParams.get('registry');
|
||||
const imageName = url.searchParams.get('image');
|
||||
|
||||
if (!imageName) {
|
||||
return json({ error: 'Image name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let tags: TagInfo[];
|
||||
|
||||
if (!registryId) {
|
||||
// No registry specified, assume Docker Hub
|
||||
tags = await fetchDockerHubTags(imageName);
|
||||
} else {
|
||||
const registry = await getRegistry(parseInt(registryId));
|
||||
if (!registry) {
|
||||
return json({ error: 'Registry not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (isDockerHub(registry.url)) {
|
||||
tags = await fetchDockerHubTags(imageName);
|
||||
} else {
|
||||
tags = await fetchRegistryTags(registry, imageName);
|
||||
}
|
||||
}
|
||||
|
||||
return json(tags);
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching tags:', error);
|
||||
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return json({ error: 'Could not connect to registry' }, { status: 503 });
|
||||
}
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return json({ error: 'Registry host not found' }, { status: 503 });
|
||||
}
|
||||
|
||||
return json({ error: error.message || 'Failed to fetch tags' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user