mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 19:54:15 +00:00
digest_content.go branched on Error == "never-scanned" to pick the "not scanned yet" copy, but nothing anywhere produced that string: both FetchVulnDetails and FetchSbomDetails returned the human sentence "No scan record found" for a missing record. So vulnReason and sbomReason could never be "not-scanned", the friendly branches in vulns-section.html and sbom-section.html were dead code, and every unscanned image fell through to fetch-failed. Free-tier accounts have scan_on_push off, so this was every image they push, told "Scan data couldn't be loaded... try refreshing in a minute" about something that had never been scanned and never would be by refreshing. The digest page showed the raw internal string instead. Replace the prose sentinel with a NotScanned bool the 404 path actually sets, and give other non-200 statuses a distinct message so a 500 from the hold stops being indistinguishable from an absent record. The detail templates branch on it before Error, so nothing leaks the internal value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UAqi2hS2dhZoatqcWoYZQk
226 lines
7.1 KiB
Go
226 lines
7.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/holdclient"
|
|
"atcr.io/pkg/appview/middleware"
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// DigestContentHandler returns the layers + vulnerabilities HTML fragment
|
|
// for a specific platform manifest. Used by the arch dropdown via HTMX.
|
|
type DigestContentHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *DigestContentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
identifier := chi.URLParam(r, "handle")
|
|
wildcard := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
|
|
|
|
repository := wildcard
|
|
digest := r.URL.Query().Get("digest")
|
|
if digest == "" || repository == "" {
|
|
http.Error(w, "missing parameters", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
did, _, _, err := atproto.ResolveIdentity(r.Context(), identifier)
|
|
if err != nil {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
manifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, digest)
|
|
if err != nil {
|
|
http.Error(w, "manifest not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.Key)
|
|
if err != nil {
|
|
slog.Warn("Failed to fetch layers", "error", err)
|
|
}
|
|
|
|
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint)
|
|
holdReachable := holdErr == nil
|
|
|
|
// Helm charts have no scannable layers / vulns / SBOM. Render helm-aware
|
|
// content for the default + "chart" sections, and a not-applicable
|
|
// placeholder for the legacy layers / vulns / sbom sections (which
|
|
// shouldn't be requested for helm but might be if a stale tab fires).
|
|
if manifest.ArtifactType == db.ArtifactTypeHelmChart {
|
|
holdURL := ""
|
|
if holdReachable {
|
|
holdURL = hold.URL
|
|
}
|
|
helm := buildHelmContent(r.Context(), holdURL, digest, dbLayers)
|
|
if !holdReachable {
|
|
helm.HoldUnreachable = true
|
|
}
|
|
helmData := struct {
|
|
Manifest *db.ManifestWithMetadata
|
|
HelmContent *HelmChartContent
|
|
RegistryURL string
|
|
OwnerHandle string
|
|
RepoName string
|
|
OciClient string
|
|
IsLoggedIn bool
|
|
}{
|
|
Manifest: manifest,
|
|
HelmContent: helm,
|
|
RegistryURL: h.RegistryURL,
|
|
OwnerHandle: identifier,
|
|
RepoName: repository,
|
|
OciClient: "", // helm switcher ignores this field
|
|
IsLoggedIn: middleware.GetUser(r) != nil,
|
|
}
|
|
w.Header().Set("Content-Type", "text/html")
|
|
section := r.URL.Query().Get("section")
|
|
switch section {
|
|
case "chart":
|
|
// Used by the repo page's chart tab — no install card here
|
|
// because repo-tag-section already renders one at the top.
|
|
if err := h.Templates.ExecuteTemplate(w, "helm-chart-info", helmData); err != nil {
|
|
slog.Warn("Failed to render helm chart info", "error", err)
|
|
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render helm chart", err)
|
|
}
|
|
case "layers", "vulns", "sbom":
|
|
// Defensive fallback if a stale tab somehow fires. The repo page
|
|
// hides these tabs for helm; this should be unreachable.
|
|
fmt.Fprint(w, `<p class="text-base-content/70 py-8">Helm charts don't have layers, vulnerabilities, or SBOMs.</p>`)
|
|
default:
|
|
// Digest detail page (full helm view, with install card).
|
|
if err := h.Templates.ExecuteTemplate(w, "helm-digest-content", helmData); err != nil {
|
|
slog.Warn("Failed to render helm digest content", "error", err)
|
|
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render helm chart", err)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Parallelize the three hold fetches. They're independent and each
|
|
// takes a network round-trip; serial runs add up on slow links.
|
|
var (
|
|
layers []LayerDetail
|
|
vulnData *vulnDetailsData
|
|
sbomData *sbomDetailsData
|
|
configFetchError bool
|
|
)
|
|
|
|
if holdReachable {
|
|
var wg sync.WaitGroup
|
|
wg.Add(3)
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
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)
|
|
configFetchError = true
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
vd := FetchVulnDetails(r.Context(), hold.DID, digest)
|
|
vulnData = &vd
|
|
}()
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
sd := FetchSbomDetails(r.Context(), hold.DID, digest)
|
|
sbomData = &sd
|
|
}()
|
|
|
|
wg.Wait()
|
|
} else {
|
|
layers = buildLayerDetails(nil, dbLayers)
|
|
}
|
|
|
|
// VulnReason / SbomReason let the template branch distinctly on why
|
|
// data is missing instead of collapsing causes into a generic message.
|
|
// ok — data is present
|
|
// hold-unreachable — we couldn't reach the hold
|
|
// not-scanned — hold is up but no scan record exists
|
|
// not-applicable — scan record exists with status="skipped" (artifact
|
|
// type isn't scanned, e.g. in-toto, DSSE — helm
|
|
// charts go through a separate code path)
|
|
// fetch-failed — scan record fetch failed on the hold
|
|
vulnReason := "ok"
|
|
if !holdReachable {
|
|
vulnReason = "hold-unreachable"
|
|
} else if vulnData == nil || vulnData.NotScanned {
|
|
vulnReason = "not-scanned"
|
|
} else if vulnData.Status == atproto.ScanStatusSkipped {
|
|
vulnReason = "not-applicable"
|
|
} else if vulnData.Error != "" {
|
|
vulnReason = "fetch-failed"
|
|
}
|
|
|
|
sbomReason := "ok"
|
|
if !holdReachable {
|
|
sbomReason = "hold-unreachable"
|
|
} else if sbomData == nil || sbomData.NotScanned {
|
|
sbomReason = "not-scanned"
|
|
} else if sbomData.Status == atproto.ScanStatusSkipped {
|
|
sbomReason = "not-applicable"
|
|
} else if sbomData.Error != "" {
|
|
sbomReason = "fetch-failed"
|
|
}
|
|
|
|
data := struct {
|
|
Layers []LayerDetail
|
|
VulnData *vulnDetailsData
|
|
SbomData *sbomDetailsData
|
|
HoldReachable bool
|
|
ConfigFetchError bool
|
|
VulnReason string
|
|
SbomReason string
|
|
}{
|
|
Layers: layers,
|
|
VulnData: vulnData,
|
|
SbomData: sbomData,
|
|
HoldReachable: holdReachable,
|
|
ConfigFetchError: configFetchError,
|
|
VulnReason: vulnReason,
|
|
SbomReason: sbomReason,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
|
|
section := r.URL.Query().Get("section")
|
|
switch section {
|
|
case "layers":
|
|
if err := h.Templates.ExecuteTemplate(w, "layers-section", data); err != nil {
|
|
slog.Warn("Failed to render layers section", "error", err)
|
|
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render layers", err)
|
|
}
|
|
case "vulns":
|
|
if err := h.Templates.ExecuteTemplate(w, "vulns-section", data); err != nil {
|
|
slog.Warn("Failed to render vulns section", "error", err)
|
|
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render vulnerabilities", err)
|
|
}
|
|
case "sbom":
|
|
if err := h.Templates.ExecuteTemplate(w, "sbom-section", data); err != nil {
|
|
slog.Warn("Failed to render sbom section", "error", err)
|
|
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render SBOM", err)
|
|
}
|
|
default:
|
|
if err := h.Templates.ExecuteTemplate(w, "digest-content", data); err != nil {
|
|
slog.Warn("Failed to render digest content", "error", err)
|
|
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render content", err)
|
|
}
|
|
}
|
|
}
|