Files

185 lines
5.0 KiB
Go

package handlers
import (
"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
}
// 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.ReadOnlyDB, 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
if manifest.IsManifestList {
// Manifest list: no layers, show platform picker
// Platforms are already populated by GetManifestDetail
} 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 details
if holdErr == nil {
vd := FetchVulnDetails(r.Context(), hold.DID, digest)
vulnData = &vd
}
}
// Build page meta
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)
data := struct {
PageData
Meta *PageMeta
Owner *db.User
Repository string
Manifest *db.ManifestWithMetadata
Layers []LayerDetail
VulnData *vulnDetailsData
}{
PageData: NewPageData(r, &h.BaseUIHandler),
Meta: meta,
Owner: owner,
Repository: repository,
Manifest: manifest,
Layers: layers,
VulnData: vulnData,
}
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] + "..."
}