mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 09:14:16 +00:00
278 lines
8.1 KiB
Go
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] + "..."
|
|
}
|