Files
at-container-registry/pkg/appview/handlers/digest.go
T

278 lines
8.1 KiB
Go

package handlers
import (
"context"
"log/slog"
"net/http"
"strings"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdclient"
"atcr.io/pkg/atproto"
"github.com/go-chi/chi/v5"
)
// LayerDetail combines OCI config history with layer metadata from the DB.
type LayerDetail struct {
Index int
Command string // Dockerfile command (from config history)
Digest string
Size int64
MediaType string
EmptyLayer bool // ENV, LABEL, etc. — no actual layer blob
}
// HelmChartContent is the data the helm-aware digest content needs: parsed
// Chart.yaml metadata + a single chart-tarball "layer" pulled from the DB.
type HelmChartContent struct {
Meta *holdclient.HelmChartMeta
Tarball *LayerDetail
MetaFetchFailed bool // hold reachable but config blob couldn't be parsed
HoldUnreachable bool
}
// buildHelmContent fetches helm chart metadata + the single chart-tarball layer.
// Returns a populated HelmChartContent even when the meta fetch fails so the
// page can still render the artifact card.
func buildHelmContent(ctx context.Context, holdURL string, digest string, dbLayers []db.Layer) *HelmChartContent {
content := &HelmChartContent{}
if holdURL == "" {
content.HoldUnreachable = true
} else {
meta, err := holdclient.FetchHelmChartMeta(ctx, holdURL, digest)
if err != nil {
slog.Warn("Failed to fetch helm chart meta", "error", err, "digest", digest)
content.MetaFetchFailed = true
} else {
content.Meta = meta
}
}
if len(dbLayers) > 0 {
// Helm charts are always single-layer (the chart tarball). If somehow
// multiple are present, pick the one with helm chart content media
// type, falling back to the first.
chosen := 0
for i, l := range dbLayers {
if strings.Contains(l.MediaType, "helm.chart.content") {
chosen = i
break
}
}
l := dbLayers[chosen]
content.Tarball = &LayerDetail{
Index: l.LayerIndex + 1,
Digest: l.Digest,
Size: l.Size,
MediaType: l.MediaType,
}
}
return content
}
// DigestDetailHandler renders the digest detail page with layers + vulnerabilities.
type DigestDetailHandler struct {
BaseUIHandler
}
func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "handle")
// The route is /d/{handle}/*/* — first * is repo, second * is digest
// chi captures both wildcards, so we need to split the path
pathParts := strings.SplitN(strings.TrimPrefix(chi.URLParam(r, "*"), "/"), "/", 2)
if len(pathParts) < 2 {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
repository := pathParts[0]
digest := pathParts[1]
// Resolve identity
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
if err != nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
// Check for takedown labels
if taken, _ := db.IsTakenDown(h.ReadOnlyDB, did, repository); taken {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
owner, err := db.GetUserByDID(h.ReadOnlyDB, did)
if err != nil || owner == nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
if owner.Handle != resolvedHandle {
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
owner.Handle = resolvedHandle
}
// Fetch manifest details
manifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repository, digest)
if err != nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
// Build layer details
var layers []LayerDetail
var vulnData *vulnDetailsData
var sbomData *sbomDetailsData
var helmContent *HelmChartContent
if manifest.IsManifestList {
// Manifest list: no layers, show platform picker
// Platforms are already populated by GetManifestDetail
} else if manifest.ArtifactType == db.ArtifactTypeHelmChart {
// Helm chart: skip OCI history / vuln / SBOM entirely. Fetch helm
// chart metadata from the same config blob and the single tarball
// layer from the DB.
dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID)
if err != nil {
slog.Warn("Failed to fetch layers", "error", err)
}
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint)
holdURL := ""
if holdErr == nil {
holdURL = hold.URL
}
helmContent = buildHelmContent(r.Context(), holdURL, digest, dbLayers)
if holdErr != nil {
helmContent.HoldUnreachable = true
}
} else {
// Single manifest: fetch layers from DB
dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID)
if err != nil {
slog.Warn("Failed to fetch layers", "error", err)
}
// Resolve hold endpoint (follow successor if migrated)
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint)
// Fetch OCI image config from hold for layer history (including empty layers)
if holdErr == nil {
config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest)
if err == nil {
layers = buildLayerDetails(config.History, dbLayers)
} else {
slog.Warn("Failed to fetch image config", "error", err,
"holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest)
layers = buildLayerDetails(nil, dbLayers)
}
} else {
layers = buildLayerDetails(nil, dbLayers)
}
// Fetch vulnerability and SBOM details
if holdErr == nil {
vd := FetchVulnDetails(r.Context(), hold.DID, digest)
vulnData = &vd
sd := FetchSbomDetails(r.Context(), hold.DID, digest)
sbomData = &sd
}
}
// Determine selected platform for multi-arch manifests
selectedPlatform := r.URL.Query().Get("platform")
if manifest.IsManifestList && len(manifest.Platforms) > 0 {
if selectedPlatform == "" {
selectedPlatform = manifest.Platforms[0].Digest
}
}
// Build page meta
var title string
if len(manifest.Tags) > 0 {
title = strings.Join(manifest.Tags, ", ") + " - " + owner.Handle + "/" + repository + " - " + h.ClientShortName
} else {
title = truncateDigestStr(digest, 16) + " - " + owner.Handle + "/" + repository + " - " + h.ClientShortName
}
description := "Image digest " + digest + " in " + owner.Handle + "/" + repository
meta := NewPageMeta(title, description).
WithCanonical("https://" + h.SiteURL + "/d/" + owner.Handle + "/" + repository + "/" + digest).
WithSiteName(h.ClientShortName)
pageData := NewPageData(r, &h.BaseUIHandler)
data := struct {
PageData
Meta *PageMeta
Owner *db.User
Repository string
Manifest *db.ManifestWithMetadata
Layers []LayerDetail
VulnData *vulnDetailsData
SbomData *sbomDetailsData
HelmContent *HelmChartContent
SelectedPlatform string
RegistryURL string
OciClient string
}{
PageData: pageData,
Meta: meta,
Owner: owner,
Repository: repository,
Manifest: manifest,
Layers: layers,
VulnData: vulnData,
SbomData: sbomData,
HelmContent: helmContent,
SelectedPlatform: selectedPlatform,
RegistryURL: h.RegistryURL,
OciClient: pageData.OciClient,
}
if err := h.Templates.ExecuteTemplate(w, "digest", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// buildLayerDetails correlates OCI config history entries with layer metadata.
// History entries with empty_layer=true have no corresponding layer blob.
func buildLayerDetails(history []holdclient.OCIHistoryEntry, dbLayers []db.Layer) []LayerDetail {
var details []LayerDetail
layerIdx := 0
if len(history) == 0 {
// No config history available — just list layers from DB
for _, l := range dbLayers {
details = append(details, LayerDetail{
Index: l.LayerIndex + 1,
Digest: l.Digest,
Size: l.Size,
MediaType: l.MediaType,
})
}
return details
}
for i, h := range history {
ld := LayerDetail{
Index: i + 1,
Command: h.CreatedBy,
EmptyLayer: h.EmptyLayer,
}
if !h.EmptyLayer && layerIdx < len(dbLayers) {
ld.Digest = dbLayers[layerIdx].Digest
ld.Size = dbLayers[layerIdx].Size
ld.MediaType = dbLayers[layerIdx].MediaType
layerIdx++
}
details = append(details, ld)
}
return details
}
func truncateDigestStr(s string, length int) string {
if len(s) <= length {
return s
}
return s[:length] + "..."
}