Files
dockhand-upstream/src/routes/api/registry/search/+server.ts
T

249 lines
7.2 KiB
TypeScript

import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth, isHarborRegistry, harborSearchRepositories, parseRegistryUrl } from '$lib/server/docker';
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[]> {
const results: string[] = [];
const { orgPath } = parseRegistryUrl(registry.url);
const orgPrefix = orgPath ? orgPath.replace(/^\//, '') : '';
// Strategy 1: Direct image lookup — try the exact term and org-prefixed variants
// This uses per-repository auth scope which works on all V2 registries (GitLab, Harbor, etc.)
const directCandidates: string[] = [];
if (term.includes('/')) {
directCandidates.push(term);
}
// If registry URL has an org path (e.g., https://registry.example.com/group),
// try prepending it to the search term
if (orgPrefix && !term.startsWith(orgPrefix + '/')) {
directCandidates.push(`${orgPrefix}/${term}`);
}
for (const candidate of directCandidates) {
if (results.length >= limit) break;
const exists = await tryDirectImageLookup(registry, candidate);
if (exists && !results.includes(candidate)) {
results.push(candidate);
}
}
// Strategy 2: Fall back to catalog search for partial/fuzzy matches
// Some registries (GitLab, Harbor) don't support _catalog for deploy tokens,
// so catch errors gracefully and return whatever we have from direct lookup
if (results.length < limit) {
try {
const catalogResults = await searchCatalog(registry, term, limit - results.length);
for (const name of catalogResults) {
if (!results.includes(name)) {
results.push(name);
}
}
} catch (e: any) {
// Catalog not supported but we have direct lookup results — that's fine
if (results.length > 0) {
console.warn(`[Registry] Catalog search failed (using direct lookup results): ${e.message}`);
} else {
throw e;
}
}
}
return results.map((name: string) => ({
name,
description: '',
star_count: 0,
is_official: false,
is_automated: false
}));
}
// Try to directly check if an image exists by querying its tags endpoint
async function tryDirectImageLookup(registry: any, imageName: string): Promise<boolean> {
try {
// Note: orgPath is not used here because imageName already contains the full repo path
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
const response = await fetch(`${baseUrl}/v2/${imageName}/tags/list`, {
method: 'GET',
headers
});
// 200 = image exists, 404 = doesn't exist
return response.ok;
} catch {
return false;
}
}
// Search through catalog (slow for large registries, limited to first few pages)
async function searchCatalog(registry: any, term: string, limit: number): Promise<string[]> {
// Harbor fallback: use the native project API for search
if (await isHarborRegistry(registry.url)) {
const { path: orgPath } = parseRegistryUrl(registry.url);
return harborSearchRepositories(registry, term, orgPath, limit);
}
// Note: orgPath could be used here to filter results, but search is already term-based
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
const termLower = term.toLowerCase();
const results: string[] = [];
const PAGE_SIZE = 200;
const MAX_PAGES = 3; // Limit pages to avoid long waits on huge registries
let lastRepo: string | null = null;
let pagesSearched = 0;
while (results.length < limit && pagesSearched < MAX_PAGES) {
let catalogUrl = `${baseUrl}/v2/_catalog?n=${PAGE_SIZE}`;
if (lastRepo) {
catalogUrl += `&last=${encodeURIComponent(lastRepo)}`;
}
const response = await fetch(catalogUrl, {
method: 'GET',
headers
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new Error('Authentication failed. This registry may not support catalog listing (common with GitLab and Harbor deploy tokens).');
}
throw new Error(`Registry returned error: ${response.status}`);
}
const data = await response.json();
const repositories: string[] = data.repositories || [];
if (repositories.length === 0) {
break;
}
// Filter and add matching repos
for (const name of repositories) {
if (name.toLowerCase().includes(termLower)) {
results.push(name);
if (results.length >= limit) {
break;
}
}
}
// Get last repo for next page
lastRepo = repositories[repositories.length - 1];
// Check if there are more pages
const linkHeader = response.headers.get('Link');
if (!linkHeader || !linkHeader.includes('rel="next"')) {
if (repositories.length < PAGE_SIZE) {
break;
}
}
pagesSearched++;
}
return results;
}
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 });
}
};