Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions src/lib/server/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2462,6 +2462,202 @@ export async function getRegistryAuth(
return { baseUrl, orgPath: parsed.path, authHeader };
}

// --- Harbor fallback pour le catalog et la recherche d'images ---
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi @pguinet - we need all comments to be in english.

Copy link
Author

@pguinet pguinet Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @jotka - Sorry about that! I've translated all comments to English in the latest commit. Won't happen again.

// Harbor interdit l'accès au endpoint V2 _catalog pour les robots.
// On détecte Harbor et on utilise l'API projet native en fallback.

/** Cache de détection Harbor par host (TTL 5 min) */
const harborDetectionCache = new Map<string, { isHarbor: boolean; ts: number }>();
const HARBOR_CACHE_TTL = 5 * 60 * 1000;

export interface HarborCatalogResult {
repositories: string[];
/** Curseur de pagination : "harbor:<page>" ou null si dernière page */
nextLast: string | null;
}

/**
* Détecte si un registry est une instance Harbor.
* Vérifie service="harbor-registry" dans le header WWW-Authenticate de /v2/,
* puis confirme via /api/v2.0/ping. Résultat mis en cache 5 min par host.
*/
export async function isHarborRegistry(registryUrl: string): Promise<boolean> {
const parsed = parseRegistryUrl(registryUrl);
const host = parsed.host;

const cached = harborDetectionCache.get(host);
if (cached && Date.now() - cached.ts < HARBOR_CACHE_TTL) {
return cached.isHarbor;
}

let isHarbor = false;
try {
const baseUrl = `https://${host}`;

// Étape 1 : vérifier le header WWW-Authenticate de /v2/
const challengeResp = await fetch(`${baseUrl}/v2/`, {
method: 'GET',
headers: { 'User-Agent': 'Dockhand/1.0' }
});
const wwwAuth = challengeResp.headers.get('WWW-Authenticate') || '';
if (wwwAuth.toLowerCase().includes('service="harbor-registry"')) {
// Étape 2 : confirmer via /api/v2.0/ping
const pingResp = await fetch(`${baseUrl}/api/v2.0/ping`, {
method: 'GET',
headers: { 'User-Agent': 'Dockhand/1.0' }
});
if (pingResp.ok) {
const body = await pingResp.text();
if (body.includes('Pong')) {
isHarbor = true;
}
}
}
} catch {
// En cas d'erreur réseau, on considère que ce n'est pas Harbor
}

harborDetectionCache.set(host, { isHarbor, ts: Date.now() });
return isHarbor;
}

/**
* Construit le header Basic auth pour l'API Harbor à partir d'un objet registry.
*/
function getHarborBasicAuth(registry: { username?: string | null; password?: string | null }): string | null {
if (registry.username && registry.password) {
return `Basic ${Buffer.from(`${registry.username}:${registry.password}`).toString('base64')}`;
}
return null;
}

/**
* Liste les repositories via l'API projet Harbor.
* Si orgPath est défini → un seul projet. Sinon → énumère tous les projets accessibles.
* @param page - numéro de page (1-based)
* @param pageSize - nombre de résultats par page
*/
export async function harborListRepositories(
registry: { url: string; username?: string | null; password?: string | null },
orgPath: string,
page: number = 1,
pageSize: number = 100
): Promise<HarborCatalogResult> {
const parsed = parseRegistryUrl(registry.url);
const baseUrl = `https://${parsed.host}/api/v2.0`;
const authHeader = getHarborBasicAuth(registry);

const headers: Record<string, string> = {
'Accept': 'application/json',
'User-Agent': 'Dockhand/1.0'
};
if (authHeader) headers['Authorization'] = authHeader;

const repositories: string[] = [];
let totalCount = 0;

if (orgPath) {
// Un seul projet : le path sans le slash initial
const project = orgPath.replace(/^\//, '');
const url = `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?page=${page}&page_size=${pageSize}`;
const resp = await fetch(url, { headers });

if (!resp.ok) {
throw new Error(`Harbor API erreur ${resp.status} pour le projet ${project}`);
}

totalCount = parseInt(resp.headers.get('X-Total-Count') || '0', 10);
const repos: Array<{ name: string }> = await resp.json();
for (const r of repos) {
repositories.push(r.name);
}
} else {
// Pas d'orgPath : énumérer tous les projets accessibles
const projectsResp = await fetch(`${baseUrl}/projects?page=1&page_size=100`, { headers });
if (!projectsResp.ok) {
throw new Error(`Harbor API erreur ${projectsResp.status} pour la liste des projets`);
}
const projects: Array<{ name: string }> = await projectsResp.json();

// Paginer les repos du premier projet correspondant à la page demandée
// Pour simplifier, on concatène tous les repos de tous les projets
for (const proj of projects) {
const url = `${baseUrl}/projects/${encodeURIComponent(proj.name)}/repositories?page=1&page_size=100`;
const resp = await fetch(url, { headers });
if (!resp.ok) continue;

const repos: Array<{ name: string }> = await resp.json();
for (const r of repos) {
repositories.push(r.name);
}
}
totalCount = repositories.length;
}

// Calculer si il y a une page suivante
const hasMore = orgPath ? (page * pageSize < totalCount) : false;
const nextLast = hasMore ? `harbor:${page + 1}` : null;

return { repositories, nextLast };
}

/**
* Recherche des repositories via l'API Harbor avec filtre q=name=~{term}.
* Parcourt tous les projets accessibles (ou un seul si orgPath défini).
* Double vérification substring côté client.
*/
export async function harborSearchRepositories(
registry: { url: string; username?: string | null; password?: string | null },
term: string,
orgPath: string,
limit: number = 25
): Promise<string[]> {
const parsed = parseRegistryUrl(registry.url);
const baseUrl = `https://${parsed.host}/api/v2.0`;
const authHeader = getHarborBasicAuth(registry);

const headers: Record<string, string> = {
'Accept': 'application/json',
'User-Agent': 'Dockhand/1.0'
};
if (authHeader) headers['Authorization'] = authHeader;

const termLower = term.toLowerCase();
const results: string[] = [];

// Déterminer les projets à parcourir
let projectNames: string[];
if (orgPath) {
projectNames = [orgPath.replace(/^\//, '')];
} else {
const projectsResp = await fetch(`${baseUrl}/projects?page=1&page_size=100`, { headers });
if (!projectsResp.ok) return results;
const projects: Array<{ name: string }> = await projectsResp.json();
projectNames = projects.map(p => p.name);
}

// Chercher dans chaque projet avec le filtre Harbor
for (const proj of projectNames) {
if (results.length >= limit) break;

const q = encodeURIComponent(`name=~${term}`);
const url = `${baseUrl}/projects/${encodeURIComponent(proj)}/repositories?q=${q}&page=1&page_size=${limit}`;
const resp = await fetch(url, { headers });
if (!resp.ok) continue;

const repos: Array<{ name: string }> = await resp.json();
for (const r of repos) {
// Double vérification côté client
if (r.name.toLowerCase().includes(termLower)) {
results.push(r.name);
if (results.length >= limit) break;
}
}
}

return results;
}

/**
* Check the registry for the current manifest digest of an image.
* Simple HEAD request to get Docker-Content-Digest header.
Expand Down
44 changes: 43 additions & 1 deletion src/routes/api/registry/catalog/+server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
import { getRegistryAuth, isHarborRegistry, harborListRepositories, parseRegistryUrl } from '$lib/server/docker';

const PAGE_SIZE = 100;

Expand All @@ -24,6 +24,12 @@ export const GET: RequestHandler = async ({ url }) => {
return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 });
}

// Fallback Harbor : l'endpoint _catalog est interdit pour les robots Harbor.
// On utilise l'API projet native à la place.
if (await isHarborRegistry(registry.url)) {
return handleHarborCatalog(registry, lastParam);
}

const { baseUrl, orgPath, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');

// Build catalog URL with pagination
Expand Down Expand Up @@ -114,3 +120,39 @@ export const GET: RequestHandler = async ({ url }) => {
return json({ error: 'Failed to fetch catalog: ' + (error.message || 'Unknown error') }, { status: 500 });
}
};

/**
* Gère le catalog pour un registry Harbor via l'API projet native.
* Décode le curseur "harbor:N" pour la pagination.
*/
async function handleHarborCatalog(
registry: { url: string; username?: string | null; password?: string | null },
lastParam: string | null
): Promise<Response> {
const { path: orgPath } = parseRegistryUrl(registry.url);

// Décoder le curseur Harbor : "harbor:<page>" → numéro de page
let page = 1;
if (lastParam?.startsWith('harbor:')) {
page = parseInt(lastParam.substring(7), 10) || 1;
}

const result = await harborListRepositories(registry, orgPath, page, PAGE_SIZE);

const results = result.repositories.map((name: string) => ({
name,
description: '',
star_count: 0,
is_official: false,
is_automated: false
}));

return json({
repositories: results,
pagination: {
pageSize: PAGE_SIZE,
hasMore: !!result.nextLast,
nextLast: result.nextLast
}
});
}
8 changes: 7 additions & 1 deletion src/routes/api/registry/search/+server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
import { getRegistryAuth, isHarborRegistry, harborSearchRepositories, parseRegistryUrl } from '$lib/server/docker';

interface SearchResult {
name: string;
Expand Down Expand Up @@ -105,6 +105,12 @@ async function tryDirectImageLookup(registry: any, imageName: string): Promise<b

// Search through catalog (slow for large registries, limited to first few pages)
async function searchCatalog(registry: any, term: string, limit: number): Promise<string[]> {
// Fallback Harbor : utiliser l'API projet native pour la recherche
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:*');

Expand Down