Files
peardock/server/handlers/registry.js
T
Raven Scott 9775fe5305 Move Peers into Settings and add a full Registry browser tab.
Peers live under Settings; Images is local-only. Registry is a top-level view with vault credentials, Hub search, and Registry API V2 catalog/tags/manifest/delete over the wire.
2026-07-15 16:04:27 -04:00

220 lines
7.1 KiB
JavaScript

/**
* Remote registry browser RPC — catalog, tags, manifests, delete.
*/
import * as validation from '../utils/validation.js'
import {
fetchCatalog,
fetchTags,
fetchManifest,
deleteManifest,
enrichTagsWithDigests,
normalizeRegistryEndpoint,
} from '../services/registry-client.js'
import { resolveRegistryAuth } from './vault.js'
/**
* Resolve auth + server address for browser ops.
* @param {import('../rpc/session.js').PeerSession} session
* @param {object} args
*/
function resolveBrowserAuth(session, args = {}) {
const auth = resolveRegistryAuth(session, {
credentialId: args.credentialId,
auth: args.auth,
autoVault: args.autoVault !== false,
image: args.repository
? `${args.serveraddress || 'docker.io'}/${args.repository}`
: args.image,
})
const serveraddress =
args.serveraddress ||
auth?.serveraddress ||
'https://index.docker.io/v1/'
return {
auth: auth
? { username: auth.username, password: auth.password }
: null,
serveraddress: validation.sanitizeString(serveraddress, 512),
}
}
/**
* @param {import('../rpc/session.js').PeerSession} session
*/
export function registerRegistryHandlers(session) {
session.respond('registryCatalog', async (args = {}) => {
const { auth, serveraddress } = resolveBrowserAuth(session, args)
const result = await fetchCatalog(serveraddress, auth, {
n: args.n,
last: args.last,
})
return {
success: true,
type: 'registryCatalog',
serveraddress: result.endpoint.serveraddress,
host: result.endpoint.host,
isDockerHub: result.endpoint.isDockerHub,
repositories: result.repositories,
supported: result.supported,
error: result.error,
nextLast: result.nextLast,
}
})
session.respond('registryListTags', async (args = {}) => {
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
if (!repository) throw new Error('repository required')
const { auth, serveraddress } = resolveBrowserAuth(session, {
...args,
repository,
})
const result = await fetchTags(repository, serveraddress, auth)
let tagsDetail = null
if (args.enrich && result.tags.length) {
tagsDetail = await enrichTagsWithDigests(
result.repository,
result.tags,
serveraddress,
auth,
{ limit: args.enrichLimit || 40 }
)
}
return {
success: true,
type: 'registryTags',
serveraddress: result.endpoint.serveraddress,
host: result.endpoint.host,
repository: result.repository,
tags: result.tags,
tagsDetail,
tagCount: result.tags.length,
}
})
session.respond('registryGetManifest', async (args = {}) => {
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
const reference = validation.sanitizeString(
args.reference || args.tag || args.digest || '',
256
)
if (!repository || !reference) throw new Error('repository and reference required')
const { auth, serveraddress } = resolveBrowserAuth(session, {
...args,
repository,
})
const result = await fetchManifest(repository, reference, serveraddress, auth)
// Don't ship full layer blobs — trim manifest for UI
let manifestSummary = result.manifest
if (manifestSummary && typeof manifestSummary === 'object') {
manifestSummary = {
schemaVersion: manifestSummary.schemaVersion,
mediaType: manifestSummary.mediaType,
architecture: manifestSummary.architecture,
os: manifestSummary.os,
config: manifestSummary.config
? { digest: manifestSummary.config.digest, size: manifestSummary.config.size, mediaType: manifestSummary.config.mediaType }
: undefined,
layers: Array.isArray(manifestSummary.layers)
? manifestSummary.layers.map((l) => ({
digest: l.digest,
size: l.size,
mediaType: l.mediaType,
}))
: undefined,
manifests: Array.isArray(manifestSummary.manifests)
? manifestSummary.manifests.map((m) => ({
digest: m.digest,
mediaType: m.mediaType,
platform: m.platform,
size: m.size,
}))
: undefined,
}
}
return {
success: true,
type: 'registryManifest',
serveraddress: result.endpoint.serveraddress,
host: result.endpoint.host,
repository: result.repository,
reference: result.reference,
digest: result.digest,
mediaType: result.mediaType,
sizeBytes: result.sizeBytes,
platformCount: result.platformCount,
architecture: result.architecture,
os: result.os,
childDigests: result.childDigests,
manifest: manifestSummary,
}
})
session.respond('registryDeleteTag', async (args = {}) => {
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
const reference = validation.sanitizeString(
args.reference || args.tag || args.digest || '',
256
)
if (!repository || !reference) throw new Error('repository and reference required')
const { auth, serveraddress } = resolveBrowserAuth(session, {
...args,
repository,
})
if (!auth) {
throw Object.assign(new Error('Authenticated credential required to delete remote tags'), {
code: 'AUTH_REQUIRED',
})
}
const result = await deleteManifest(repository, reference, serveraddress, auth)
return {
success: true,
type: 'registryDelete',
...result,
host: result.endpoint?.host,
serveraddress: result.endpoint?.serveraddress,
}
})
session.respond('registryDeleteTags', async (args = {}) => {
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
const tags = Array.isArray(args.tags)
? args.tags.map((t) => String(t || '').trim()).filter(Boolean)
: []
if (!repository || !tags.length) throw new Error('repository and tags[] required')
if (tags.length > 50) throw new Error('Max 50 tags per bulk delete')
const { auth, serveraddress } = resolveBrowserAuth(session, {
...args,
repository,
})
if (!auth) {
throw Object.assign(new Error('Authenticated credential required to delete remote tags'), {
code: 'AUTH_REQUIRED',
})
}
/** @type {Array<object>} */
const results = []
for (const tag of tags) {
try {
const r = await deleteManifest(repository, tag, serveraddress, auth)
results.push({ tag, ok: true, digest: r.digest, deleted: r.deleted })
} catch (err) {
results.push({ tag, ok: false, error: err.message })
}
}
const deleted = results.filter((r) => r.ok && r.deleted).length
return {
success: true,
type: 'registryBulkDelete',
repository,
deleted,
failed: results.filter((r) => !r.ok).length,
results,
}
})
session.respond('registryNormalizeEndpoint', async (args = {}) => {
const ep = normalizeRegistryEndpoint(args.serveraddress || args.server || '')
return { success: true, type: 'registryEndpoint', ...ep }
})
}