A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add new files for getting image configs from hold etc

+932
+37
lexicons/io/atcr/hold/getLayersForManifest.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.getLayersForManifest", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Returns layer records for a specific manifest AT-URI.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["manifest"], 11 + "properties": { 12 + "manifest": { 13 + "type": "string", 14 + "format": "at-uri", 15 + "description": "AT-URI of the manifest to get layers for" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": ["layers"], 24 + "properties": { 25 + "layers": { 26 + "type": "array", 27 + "items": { 28 + "type": "ref", 29 + "ref": "io.atcr.hold.layer" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+32
lexicons/io/atcr/hold/image/config.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.image.config", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "any", 8 + "description": "OCI image configuration for a container manifest. Stored in the hold's embedded PDS. Record key is the manifest digest hex without the 'sha256:' prefix (deterministic, one per manifest). Contains the full OCI config JSON including history (Dockerfile commands), environment variables, entrypoint, labels, etc.", 9 + "record": { 10 + "type": "object", 11 + "required": ["manifest", "configJson", "createdAt"], 12 + "properties": { 13 + "manifest": { 14 + "type": "string", 15 + "format": "at-uri", 16 + "description": "AT-URI of the manifest this config belongs to" 17 + }, 18 + "configJson": { 19 + "type": "string", 20 + "description": "Raw OCI image config JSON blob", 21 + "maxLength": 65536 22 + }, 23 + "createdAt": { 24 + "type": "string", 25 + "format": "datetime", 26 + "description": "RFC3339 timestamp of when the config was stored" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+28
lexicons/io/atcr/hold/image/getConfig.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.image.getConfig", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Returns the OCI image config record for a specific manifest digest.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["digest"], 11 + "properties": { 12 + "digest": { 13 + "type": "string", 14 + "description": "Manifest digest (e.g., sha256:abc123...)", 15 + "maxLength": 128 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "ref", 23 + "ref": "io.atcr.hold.image.config" 24 + } 25 + } 26 + } 27 + } 28 + }
+178
pkg/appview/handlers/digest.go
··· 1 + package handlers 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "strings" 7 + 8 + "atcr.io/pkg/appview/db" 9 + "atcr.io/pkg/appview/holdclient" 10 + "atcr.io/pkg/atproto" 11 + "github.com/go-chi/chi/v5" 12 + ) 13 + 14 + // LayerDetail combines OCI config history with layer metadata from the DB. 15 + type LayerDetail struct { 16 + Index int 17 + Command string // Dockerfile command (from config history) 18 + Digest string 19 + Size int64 20 + MediaType string 21 + EmptyLayer bool // ENV, LABEL, etc. — no actual layer blob 22 + } 23 + 24 + // DigestDetailHandler renders the digest detail page with layers + vulnerabilities. 25 + type DigestDetailHandler struct { 26 + BaseUIHandler 27 + } 28 + 29 + func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 30 + identifier := chi.URLParam(r, "handle") 31 + // The route is /d/{handle}/*/* — first * is repo, second * is digest 32 + // chi captures both wildcards, so we need to split the path 33 + pathParts := strings.SplitN(strings.TrimPrefix(chi.URLParam(r, "*"), "/"), "/", 2) 34 + if len(pathParts) < 2 { 35 + RenderNotFound(w, r, &h.BaseUIHandler) 36 + return 37 + } 38 + repository := pathParts[0] 39 + digest := pathParts[1] 40 + 41 + // Resolve identity 42 + did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier) 43 + if err != nil { 44 + RenderNotFound(w, r, &h.BaseUIHandler) 45 + return 46 + } 47 + 48 + owner, err := db.GetUserByDID(h.ReadOnlyDB, did) 49 + if err != nil || owner == nil { 50 + RenderNotFound(w, r, &h.BaseUIHandler) 51 + return 52 + } 53 + if owner.Handle != resolvedHandle { 54 + _ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle) 55 + owner.Handle = resolvedHandle 56 + } 57 + 58 + // Fetch manifest details 59 + manifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repository, digest) 60 + if err != nil { 61 + RenderNotFound(w, r, &h.BaseUIHandler) 62 + return 63 + } 64 + 65 + // Build layer details 66 + var layers []LayerDetail 67 + var vulnData *vulnDetailsData 68 + 69 + if manifest.IsManifestList { 70 + // Manifest list: no layers, show platform picker 71 + // Platforms are already populated by GetManifestDetail 72 + } else { 73 + // Single manifest: fetch layers from DB 74 + dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID) 75 + if err != nil { 76 + slog.Warn("Failed to fetch layers", "error", err) 77 + } 78 + 79 + // Resolve hold endpoint (follow successor if migrated) 80 + hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint) 81 + 82 + // Fetch OCI image config from hold for layer history (including empty layers) 83 + if holdErr == nil { 84 + config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest) 85 + if err == nil { 86 + layers = buildLayerDetails(config.History, dbLayers) 87 + } else { 88 + slog.Warn("Failed to fetch image config", "error", err, 89 + "holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest) 90 + layers = buildLayerDetails(nil, dbLayers) 91 + } 92 + } else { 93 + layers = buildLayerDetails(nil, dbLayers) 94 + } 95 + 96 + // Fetch vulnerability details 97 + if holdErr == nil { 98 + vd := FetchVulnDetails(r.Context(), hold.DID, digest) 99 + vulnData = &vd 100 + } 101 + } 102 + 103 + // Build page meta 104 + title := truncateDigestStr(digest, 16) + " - " + owner.Handle + "/" + repository + " - " + h.ClientShortName 105 + description := "Image digest " + digest + " in " + owner.Handle + "/" + repository 106 + 107 + meta := NewPageMeta(title, description). 108 + WithCanonical("https://" + h.SiteURL + "/d/" + owner.Handle + "/" + repository + "/" + digest). 109 + WithSiteName(h.ClientShortName) 110 + 111 + data := struct { 112 + PageData 113 + Meta *PageMeta 114 + Owner *db.User 115 + Repository string 116 + Manifest *db.ManifestWithMetadata 117 + Layers []LayerDetail 118 + VulnData *vulnDetailsData 119 + }{ 120 + PageData: NewPageData(r, &h.BaseUIHandler), 121 + Meta: meta, 122 + Owner: owner, 123 + Repository: repository, 124 + Manifest: manifest, 125 + Layers: layers, 126 + VulnData: vulnData, 127 + } 128 + 129 + if err := h.Templates.ExecuteTemplate(w, "digest", data); err != nil { 130 + http.Error(w, err.Error(), http.StatusInternalServerError) 131 + } 132 + } 133 + 134 + // buildLayerDetails correlates OCI config history entries with layer metadata. 135 + // History entries with empty_layer=true have no corresponding layer blob. 136 + func buildLayerDetails(history []holdclient.OCIHistoryEntry, dbLayers []db.Layer) []LayerDetail { 137 + var details []LayerDetail 138 + layerIdx := 0 139 + 140 + if len(history) == 0 { 141 + // No config history available — just list layers from DB 142 + for _, l := range dbLayers { 143 + details = append(details, LayerDetail{ 144 + Index: l.LayerIndex + 1, 145 + Digest: l.Digest, 146 + Size: l.Size, 147 + MediaType: l.MediaType, 148 + }) 149 + } 150 + return details 151 + } 152 + 153 + for i, h := range history { 154 + ld := LayerDetail{ 155 + Index: i + 1, 156 + Command: h.CreatedBy, 157 + EmptyLayer: h.EmptyLayer, 158 + } 159 + 160 + if !h.EmptyLayer && layerIdx < len(dbLayers) { 161 + ld.Digest = dbLayers[layerIdx].Digest 162 + ld.Size = dbLayers[layerIdx].Size 163 + ld.MediaType = dbLayers[layerIdx].MediaType 164 + layerIdx++ 165 + } 166 + 167 + details = append(details, ld) 168 + } 169 + 170 + return details 171 + } 172 + 173 + func truncateDigestStr(s string, length int) string { 174 + if len(s) <= length { 175 + return s 176 + } 177 + return s[:length] + "..." 178 + }
+143
pkg/appview/handlers/digest_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "testing" 5 + 6 + "atcr.io/pkg/appview/db" 7 + "atcr.io/pkg/appview/holdclient" 8 + ) 9 + 10 + func TestBuildLayerDetails_NoHistory(t *testing.T) { 11 + dbLayers := []db.Layer{ 12 + {Digest: "sha256:aaa", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0}, 13 + {Digest: "sha256:bbb", Size: 2048, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1}, 14 + } 15 + 16 + details := buildLayerDetails(nil, dbLayers) 17 + 18 + if len(details) != 2 { 19 + t.Fatalf("Expected 2 layer details, got %d", len(details)) 20 + } 21 + if details[0].Command != "" { 22 + t.Errorf("Expected empty command, got %q", details[0].Command) 23 + } 24 + if details[0].Digest != "sha256:aaa" { 25 + t.Errorf("Expected digest sha256:aaa, got %q", details[0].Digest) 26 + } 27 + if details[0].Index != 1 { 28 + t.Errorf("Expected Index 1, got %d", details[0].Index) 29 + } 30 + } 31 + 32 + func TestBuildLayerDetails_WithHistory(t *testing.T) { 33 + history := []holdclient.OCIHistoryEntry{ 34 + {CreatedBy: "ENV PATH=/usr/local/bin", EmptyLayer: true}, 35 + {CreatedBy: "RUN apt-get install curl", EmptyLayer: false}, 36 + {CreatedBy: "COPY . /app", EmptyLayer: false}, 37 + } 38 + 39 + dbLayers := []db.Layer{ 40 + {Digest: "sha256:aaa", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0}, 41 + {Digest: "sha256:bbb", Size: 2048, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1}, 42 + } 43 + 44 + details := buildLayerDetails(history, dbLayers) 45 + 46 + if len(details) != 3 { 47 + t.Fatalf("Expected 3 details (1 empty + 2 real), got %d", len(details)) 48 + } 49 + 50 + // First entry is empty layer (ENV) 51 + if !details[0].EmptyLayer { 52 + t.Error("Expected first detail to be empty layer") 53 + } 54 + if details[0].Command != "ENV PATH=/usr/local/bin" { 55 + t.Errorf("Expected ENV command, got %q", details[0].Command) 56 + } 57 + if details[0].Digest != "" { 58 + t.Errorf("Expected empty digest for empty layer, got %q", details[0].Digest) 59 + } 60 + 61 + // Second entry is real layer with digest 62 + if details[1].EmptyLayer { 63 + t.Error("Expected second detail to not be empty layer") 64 + } 65 + if details[1].Digest != "sha256:aaa" { 66 + t.Errorf("Expected digest sha256:aaa, got %q", details[1].Digest) 67 + } 68 + if details[1].Command != "RUN apt-get install curl" { 69 + t.Errorf("Expected RUN command, got %q", details[1].Command) 70 + } 71 + 72 + // Third entry is real layer 73 + if details[2].Digest != "sha256:bbb" { 74 + t.Errorf("Expected digest sha256:bbb, got %q", details[2].Digest) 75 + } 76 + if details[2].Command != "COPY . /app" { 77 + t.Errorf("Expected COPY command, got %q", details[2].Command) 78 + } 79 + } 80 + 81 + func TestBuildLayerDetails_DistrolessWithUserLayers(t *testing.T) { 82 + // Simulates distroless base (many empty layers) + user COPY layers 83 + history := []holdclient.OCIHistoryEntry{ 84 + {CreatedBy: "bazel build ...", EmptyLayer: false}, // distroless base layer 85 + {CreatedBy: "LABEL maintainer=distroless", EmptyLayer: true}, // metadata 86 + {CreatedBy: "ENV SSL_CERT_DIR=/etc/ssl/certs", EmptyLayer: true}, 87 + {CreatedBy: "COPY /app /app # buildkit", EmptyLayer: false}, // user layer 88 + } 89 + 90 + dbLayers := []db.Layer{ 91 + {Digest: "sha256:base", Size: 5000000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0}, 92 + {Digest: "sha256:user", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1}, 93 + } 94 + 95 + details := buildLayerDetails(history, dbLayers) 96 + 97 + if len(details) != 4 { 98 + t.Fatalf("Expected 4 details (2 real + 2 empty), got %d", len(details)) 99 + } 100 + 101 + // Real layer 102 + if details[0].EmptyLayer || details[0].Digest != "sha256:base" { 103 + t.Errorf("Layer 0: expected real layer with sha256:base, got empty=%v digest=%q", details[0].EmptyLayer, details[0].Digest) 104 + } 105 + 106 + // Empty layers 107 + if !details[1].EmptyLayer || details[1].Command != "LABEL maintainer=distroless" { 108 + t.Errorf("Layer 1: expected empty LABEL layer") 109 + } 110 + if !details[2].EmptyLayer || details[2].Command != "ENV SSL_CERT_DIR=/etc/ssl/certs" { 111 + t.Errorf("Layer 2: expected empty ENV layer") 112 + } 113 + 114 + // User layer 115 + if details[3].EmptyLayer || details[3].Digest != "sha256:user" { 116 + t.Errorf("Layer 3: expected real layer with sha256:user, got empty=%v digest=%q", details[3].EmptyLayer, details[3].Digest) 117 + } 118 + if details[3].Command != "COPY /app /app # buildkit" { 119 + t.Errorf("Layer 3: Command = %q, want COPY command", details[3].Command) 120 + } 121 + } 122 + 123 + func TestBuildLayerDetails_AllEmptyLayers(t *testing.T) { 124 + // Edge case: all history entries are empty layers (scratch-based with only metadata) 125 + history := []holdclient.OCIHistoryEntry{ 126 + {CreatedBy: "ENV FOO=bar", EmptyLayer: true}, 127 + {CreatedBy: "LABEL version=1.0", EmptyLayer: true}, 128 + } 129 + 130 + details := buildLayerDetails(history, nil) 131 + 132 + if len(details) != 2 { 133 + t.Fatalf("Expected 2 details, got %d", len(details)) 134 + } 135 + for i, d := range details { 136 + if !d.EmptyLayer { 137 + t.Errorf("Layer %d: expected empty layer", i) 138 + } 139 + if d.Digest != "" { 140 + t.Errorf("Layer %d: expected empty digest, got %q", i, d.Digest) 141 + } 142 + } 143 + }
+69
pkg/appview/holdclient/config.go
··· 1 + package holdclient 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + "strings" 10 + "time" 11 + 12 + "atcr.io/pkg/atproto" 13 + ) 14 + 15 + // OCIHistoryEntry represents a single entry in the OCI config history. 16 + type OCIHistoryEntry struct { 17 + Created string `json:"created"` 18 + CreatedBy string `json:"created_by"` 19 + EmptyLayer bool `json:"empty_layer"` 20 + Comment string `json:"comment"` 21 + } 22 + 23 + // OCIConfig represents the parsed OCI image config with fields useful for display. 24 + type OCIConfig struct { 25 + History []OCIHistoryEntry `json:"history"` 26 + } 27 + 28 + // FetchImageConfig fetches the OCI image config record from the hold's 29 + // getImageConfig XRPC endpoint. holdURL should be a resolved HTTP(S) URL. 30 + // Returns the parsed OCI config with history entries. 31 + func FetchImageConfig(ctx context.Context, holdURL, manifestDigest string) (*OCIConfig, error) { 32 + reqURL := fmt.Sprintf("%s%s?digest=%s", 33 + strings.TrimSuffix(holdURL, "/"), 34 + atproto.HoldGetImageConfig, 35 + url.QueryEscape(manifestDigest), 36 + ) 37 + 38 + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 39 + defer cancel() 40 + 41 + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) 42 + if err != nil { 43 + return nil, fmt.Errorf("build request: %w", err) 44 + } 45 + 46 + resp, err := http.DefaultClient.Do(req) 47 + if err != nil { 48 + return nil, fmt.Errorf("fetch image config: %w", err) 49 + } 50 + defer resp.Body.Close() 51 + 52 + if resp.StatusCode != http.StatusOK { 53 + return nil, fmt.Errorf("hold returned status %d for %s", resp.StatusCode, reqURL) 54 + } 55 + 56 + var record struct { 57 + ConfigJSON string `json:"configJson"` 58 + } 59 + if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { 60 + return nil, fmt.Errorf("parse image config response: %w", err) 61 + } 62 + 63 + var config OCIConfig 64 + if err := json.Unmarshal([]byte(record.ConfigJSON), &config); err != nil { 65 + return nil, fmt.Errorf("parse OCI config JSON: %w", err) 66 + } 67 + 68 + return &config, nil 69 + }
+215
pkg/appview/templates/pages/digest.html
··· 1 + {{ define "digest" }} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + {{ template "head" . }} 6 + {{ template "meta" .Meta }} 7 + </head> 8 + <body> 9 + {{ template "nav" . }} 10 + 11 + <main class="container mx-auto px-4 py-8"> 12 + <div class="space-y-6"> 13 + <!-- Breadcrumb --> 14 + <div class="text-sm breadcrumbs"> 15 + <ul> 16 + <li><a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a></li> 17 + <li><a href="/r/{{ .Owner.Handle }}/{{ .Repository }}" class="link link-primary">{{ .Repository }}</a></li> 18 + <li><code class="font-mono text-xs">{{ truncateDigest .Manifest.Digest 24 }}</code></li> 19 + </ul> 20 + </div> 21 + 22 + <!-- Digest Header --> 23 + <div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4"> 24 + <div class="flex flex-wrap items-start justify-between gap-4"> 25 + <div class="space-y-2"> 26 + <h1 class="text-xl font-bold font-mono break-all">{{ .Manifest.Digest }}</h1> 27 + <div class="flex flex-wrap items-center gap-2"> 28 + {{ if .Manifest.Tags }} 29 + {{ range .Manifest.Tags }} 30 + <span class="badge badge-md badge-primary">{{ . }}</span> 31 + {{ end }} 32 + {{ end }} 33 + {{ if .Manifest.IsManifestList }} 34 + <span class="badge badge-md badge-soft badge-accent">Multi-arch</span> 35 + {{ else if eq .Manifest.ArtifactType "helm-chart" }} 36 + <span class="badge badge-md badge-soft badge-helm">{{ icon "helm" "size-3" }} Helm</span> 37 + {{ end }} 38 + {{ if .Manifest.HasAttestations }} 39 + <span class="badge badge-md badge-soft badge-success">{{ icon "shield-check" "size-3" }} Attested</span> 40 + {{ end }} 41 + </div> 42 + </div> 43 + <div class="flex items-center gap-2"> 44 + <span class="text-base-content text-sm flex items-center gap-1" title="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">{{ icon "history" "size-4" }}{{ timeAgoShort .Manifest.CreatedAt }}</span> 45 + <button class="btn btn-ghost btn-sm" onclick="copyToClipboard('{{ .Manifest.Digest }}')" aria-label="Copy digest">{{ icon "copy" "size-4" }}</button> 46 + </div> 47 + </div> 48 + </div> 49 + 50 + {{ if .Manifest.IsManifestList }} 51 + <!-- Platform Picker for Manifest Lists --> 52 + <div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4"> 53 + <h2 class="text-lg font-semibold">Platforms</h2> 54 + <p class="text-sm">This is a multi-architecture manifest list. Select a platform to view layers and vulnerabilities.</p> 55 + <div class="space-y-2"> 56 + {{ range .Manifest.Platforms }} 57 + <a href="/d/{{ $.Owner.Handle }}/{{ $.Repository }}/{{ .Digest }}" class="flex items-center justify-between p-4 bg-base-200 rounded-lg hover:bg-base-300 transition-colors"> 58 + <div class="flex items-center gap-3"> 59 + <span class="font-medium">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span> 60 + <code class="font-mono text-xs text-base-content" title="{{ .Digest }}">{{ truncateDigest .Digest 32 }}</code> 61 + </div> 62 + <div class="flex items-center gap-3"> 63 + {{ if .CompressedSize }} 64 + <span class="text-sm">{{ humanizeBytes .CompressedSize }}</span> 65 + {{ end }} 66 + {{ icon "chevron-right" "size-4" }} 67 + </div> 68 + </a> 69 + {{ end }} 70 + </div> 71 + </div> 72 + {{ else }} 73 + <!-- Layers + Vulnerabilities Side by Side --> 74 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> 75 + <!-- Layers (Left) --> 76 + <div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0"> 77 + <div class="flex items-center justify-between"> 78 + <h2 class="text-lg font-semibold">Layers ({{ len .Layers }})</h2> 79 + <label class="flex items-center gap-2 text-sm cursor-pointer"> 80 + <input type="checkbox" class="checkbox checkbox-xs" id="show-empty-layers" onchange="toggleEmptyLayers(this.checked)"> 81 + <span>Show empty layers</span> 82 + </label> 83 + </div> 84 + {{ if .Layers }} 85 + <div class="overflow-x-auto"> 86 + <table class="table table-xs w-full" id="layers-table"> 87 + <thead> 88 + <tr class="text-xs"> 89 + <th class="w-8">#</th> 90 + <th>Command</th> 91 + <th class="text-right w-24">Size</th> 92 + </tr> 93 + </thead> 94 + <tbody> 95 + {{ range .Layers }} 96 + <tr data-empty="{{ .EmptyLayer }}" data-no-command="{{ and (not .Command) (not .EmptyLayer) }}"> 97 + <td class="font-mono text-xs">{{ .Index }}</td> 98 + <td> 99 + {{ if .Command }} 100 + <code class="font-mono text-xs break-all line-clamp-2" title="{{ .Command }}">{{ .Command }}</code> 101 + {{ end }} 102 + </td> 103 + <td class="text-right text-sm whitespace-nowrap">{{ humanizeBytes .Size }}</td> 104 + </tr> 105 + {{ end }} 106 + </tbody> 107 + </table> 108 + </div> 109 + <script> 110 + (function() { 111 + // Toggle empty layers (ENV, LABEL, ENTRYPOINT, etc.) 112 + var showEmpty = localStorage.getItem('showEmptyLayers') === 'true'; 113 + var checkbox = document.getElementById('show-empty-layers'); 114 + if (checkbox) checkbox.checked = showEmpty; 115 + 116 + window.toggleEmptyLayers = function(show) { 117 + localStorage.setItem('showEmptyLayers', show); 118 + applyLayerVisibility(); 119 + }; 120 + 121 + // Collapse consecutive no-command, non-empty layers (distroless base) 122 + function collapseNoHistoryLayers() { 123 + var tbody = document.querySelector('#layers-table tbody'); 124 + if (!tbody) return; 125 + 126 + var rows = Array.from(tbody.querySelectorAll('tr')); 127 + var i = 0; 128 + while (i < rows.length) { 129 + if (rows[i].dataset.noCommand === 'true') { 130 + // Find consecutive no-command rows 131 + var start = i; 132 + while (i < rows.length && rows[i].dataset.noCommand === 'true') { 133 + rows[i].classList.add('no-history-row', 'hidden'); 134 + i++; 135 + } 136 + var count = i - start; 137 + if (count > 1) { 138 + // Sum sizes from the collapsed rows 139 + var totalBytes = 0; 140 + for (var k = start; k < start + count; k++) { 141 + var sizeCell = rows[k].querySelector('td:last-child'); 142 + if (sizeCell) { 143 + var txt = sizeCell.textContent.trim(); 144 + var match = txt.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i); 145 + if (match) { 146 + var val = parseFloat(match[1]); 147 + var unit = match[2].toUpperCase(); 148 + var multipliers = {'B':1,'KB':1024,'MB':1048576,'GB':1073741824,'TB':1099511627776}; 149 + totalBytes += val * (multipliers[unit] || 1); 150 + } 151 + } 152 + } 153 + var sizeStr = ''; 154 + if (totalBytes < 1024) sizeStr = totalBytes + ' B'; 155 + else if (totalBytes < 1048576) sizeStr = (totalBytes/1024).toFixed(1) + ' KB'; 156 + else if (totalBytes < 1073741824) sizeStr = (totalBytes/1048576).toFixed(1) + ' MB'; 157 + else sizeStr = (totalBytes/1073741824).toFixed(1) + ' GB'; 158 + 159 + // Insert summary row 160 + var startIdx = rows[start].querySelector('td').textContent.trim(); 161 + var endIdx = rows[i - 1].querySelector('td').textContent.trim(); 162 + var summary = document.createElement('tr'); 163 + summary.className = 'no-history-summary cursor-pointer hover:bg-base-200'; 164 + summary.innerHTML = '<td colspan="2" class="text-sm py-2">Layers ' + startIdx + '-' + endIdx + ' contain no history <span class="text-xs ml-2">(' + count + ' layers, click to expand)</span></td><td class="text-right text-sm whitespace-nowrap">' + sizeStr + '</td>'; 165 + summary.onclick = function() { 166 + summary.remove(); 167 + for (var j = start; j < start + count; j++) { 168 + rows[j].classList.remove('hidden'); 169 + } 170 + }; 171 + tbody.insertBefore(summary, rows[start]); 172 + } else { 173 + // Single no-command row, just show it 174 + rows[start].classList.remove('hidden'); 175 + } 176 + } else { 177 + i++; 178 + } 179 + } 180 + } 181 + 182 + function applyLayerVisibility() { 183 + var show = localStorage.getItem('showEmptyLayers') === 'true'; 184 + document.querySelectorAll('#layers-table tr[data-empty="true"]').forEach(function(row) { 185 + row.style.display = show ? '' : 'none'; 186 + }); 187 + } 188 + 189 + collapseNoHistoryLayers(); 190 + applyLayerVisibility(); 191 + })(); 192 + </script> 193 + {{ else }} 194 + <p class="text-base-content">No layer information available</p> 195 + {{ end }} 196 + </div> 197 + 198 + <!-- Vulnerabilities (Right) --> 199 + <div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0"> 200 + <h2 class="text-lg font-semibold">Vulnerabilities</h2> 201 + {{ if .VulnData }} 202 + {{ template "vuln-details" .VulnData }} 203 + {{ else }} 204 + <p class="text-base-content">No vulnerability scan data available</p> 205 + {{ end }} 206 + </div> 207 + </div> 208 + {{ end }} 209 + </div> 210 + </main> 211 + 212 + {{ template "footer" . }} 213 + </body> 214 + </html> 215 + {{ end }}
+177
pkg/appview/templates/partials/repo-tags.html
··· 1 + {{ define "artifact-entry-markup" }} 2 + <div class="artifact-entry p-6" data-tag="{{ .Entry.Label }}" data-created="{{ .Entry.CreatedAt.Unix }}"> 3 + <!-- Entry Header --> 4 + <div class="flex flex-wrap items-center justify-between gap-2 mb-2"> 5 + <div class="flex flex-wrap items-center gap-2"> 6 + <span class="font-mono font-semibold{{ if not .Entry.IsTagged }} text-sm{{ end }}">{{ .Entry.Label }}</span> 7 + {{ if eq .Entry.ArtifactType "helm-chart" }} 8 + <span class="badge badge-xs badge-soft badge-helm">{{ icon "helm" "size-3" }} Helm</span> 9 + {{ else if .Entry.IsMultiArch }} 10 + <span class="badge badge-xs badge-soft badge-accent">Multi-arch</span> 11 + {{ end }} 12 + {{ if .Entry.HasAttestations }} 13 + <button class="badge badge-xs badge-soft badge-success cursor-pointer hover:opacity-80" 14 + hx-get="/api/attestation-details?digest={{ .Entry.Digest | urlquery }}&did={{ .OwnerDID | urlquery }}&repo={{ .RepoName | urlquery }}" 15 + hx-target="#attestation-modal-body" 16 + hx-swap="innerHTML" 17 + onclick="document.getElementById('attestation-detail-modal').showModal()"> 18 + {{ icon "shield-check" "size-3" }} Attested 19 + </button> 20 + {{ end }} 21 + </div> 22 + <div class="flex items-center gap-2"> 23 + {{ if eq .Entry.ArtifactType "helm-chart" }} 24 + {{ if .Entry.IsTagged }} 25 + {{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName " --version " .Entry.Label) }} 26 + {{ else }} 27 + {{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }} 28 + {{ end }} 29 + {{ else }} 30 + {{ if .Entry.IsTagged }} 31 + {{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Entry.Label) }} 32 + {{ else }} 33 + {{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }} 34 + {{ end }} 35 + {{ end }} 36 + <span class="text-base-content text-sm flex items-center gap-1" title="{{ .Entry.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">{{ icon "history" "size-4" }}{{ timeAgoShort .Entry.CreatedAt }}</span> 37 + {{ if .IsOwner }} 38 + {{ if .Entry.IsTagged }} 39 + <button class="btn btn-ghost btn-sm text-error" 40 + hx-ext="json-enc" 41 + hx-delete="/api/tags" 42 + hx-vals='{"repo": "{{ .RepoName }}", "tag": "{{ .Entry.Label }}"}' 43 + hx-confirm="Delete tag {{ .Entry.Label }}?" 44 + hx-target="closest .artifact-entry" 45 + hx-swap="outerHTML" 46 + aria-label="Delete tag {{ .Entry.Label }}"> 47 + {{ icon "trash-2" "size-4" }} 48 + </button> 49 + {{ else }} 50 + <button class="btn btn-ghost btn-sm text-error" 51 + onclick="deleteManifest('{{ .RepoName }}', '{{ .Entry.Digest }}', '')" 52 + aria-label="Delete manifest"> 53 + {{ icon "trash-2" "size-4" }} 54 + </button> 55 + {{ end }} 56 + {{ end }} 57 + </div> 58 + </div> 59 + 60 + <!-- Manifest Details Table --> 61 + <table class="table table-xs w-full table-fixed"> 62 + <colgroup> 63 + <col class="w-5/12"> 64 + <col class="w-3/12"> 65 + <col class="w-2/12"> 66 + <col class="w-2/12"> 67 + </colgroup> 68 + <thead> 69 + <tr class="text-base-content text-xs"> 70 + <th>Digest</th> 71 + <th>Vulnerabilities</th> 72 + <th>OS/Arch</th> 73 + <th>Size</th> 74 + </tr> 75 + </thead> 76 + <tbody> 77 + {{ range .Entry.Platforms }} 78 + <tr> 79 + <td> 80 + <div class="flex items-center gap-1"> 81 + <a href="/d/{{ $.OwnerHandle }}/{{ $.RepoName }}/{{ .Digest }}" class="font-mono text-xs link link-primary" title="{{ .Digest }}">{{ truncateDigest .Digest 32 }}</a> 82 + <button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Digest }}')" aria-label="Copy digest">{{ icon "copy" "size-3" }}</button> 83 + </div> 84 + </td> 85 + <td> 86 + {{ if .HoldEndpoint }} 87 + <a href="/d/{{ $.OwnerHandle }}/{{ $.RepoName }}/{{ .Digest }}" class="hover:opacity-80 transition-opacity"> 88 + <span id="scan-badge-{{ trimPrefix "sha256:" .Digest }}"></span> 89 + </a> 90 + {{ end }} 91 + </td> 92 + <td> 93 + {{ if .OS }}{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}{{ else }}-{{ end }} 94 + </td> 95 + <td class="text-sm text-base-content">{{ if .CompressedSize }}{{ humanizeBytes .CompressedSize }}{{ else }}-{{ end }}</td> 96 + </tr> 97 + {{ end }} 98 + </tbody> 99 + </table> 100 + </div> 101 + {{ end }} 102 + 103 + {{ define "load-more-button" }} 104 + {{ if .HasMore }} 105 + <div id="load-more-container" class="p-6 text-center border-t border-base-200"> 106 + <button class="btn btn-outline" 107 + hx-get="/api/repo-tags/{{ .Owner.Handle }}/{{ .Repository.Name }}?offset={{ .NextOffset }}" 108 + hx-target="#tags-list" 109 + hx-swap="beforeend" 110 + hx-on::before-request="document.getElementById('load-more-container').remove()"> 111 + Load More 112 + </button> 113 + </div> 114 + {{ end }} 115 + {{ end }} 116 + 117 + {{ define "scan-batch-triggers" }} 118 + {{ range .ScanBatchParams }} 119 + <div hx-get="/api/scan-results?{{ . }}" 120 + hx-trigger="load delay:500ms" 121 + hx-swap="none" 122 + style="display:none"></div> 123 + {{ end }} 124 + {{ end }} 125 + 126 + {{ define "repo-tags" }} 127 + <div class="space-y-4"> 128 + {{ if .Entries }} 129 + <!-- Filter/Sort Controls --> 130 + <div class="flex flex-wrap items-center gap-4"> 131 + <div class="flex items-center gap-2"> 132 + <span class="text-sm font-medium whitespace-nowrap">Sort by</span> 133 + <select id="tag-sort" class="select select-sm select-bordered min-w-28" onchange="sortTags(this.value)"> 134 + <option value="newest">Newest</option> 135 + <option value="oldest">Oldest</option> 136 + <option value="az">A-Z</option> 137 + <option value="za">Z-A</option> 138 + </select> 139 + </div> 140 + <div class="flex-1 max-w-xs"> 141 + <input type="text" id="tag-filter" class="input input-sm input-bordered w-full" placeholder="Filter artifacts..." oninput="filterTags(this.value)"> 142 + </div> 143 + {{ if $.IsOwner }} 144 + <div class="flex-none ml-auto"> 145 + <button class="btn btn-ghost btn-sm text-error" 146 + onclick="document.getElementById('untagged-delete-modal').showModal()" 147 + aria-label="Delete all untagged manifests"> 148 + {{ icon "trash-2" "size-4" }} Delete untagged 149 + </button> 150 + </div> 151 + {{ end }} 152 + </div> 153 + 154 + <!-- Manifest Entries --> 155 + <div class="card bg-base-100 shadow-sm border border-base-300"> 156 + <div class="divide-y divide-base-200" id="tags-list"> 157 + {{ range .Entries }} 158 + {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }} 159 + {{ end }} 160 + </div> 161 + {{ template "load-more-button" . }} 162 + </div> 163 + 164 + {{ template "scan-batch-triggers" . }} 165 + {{ else }} 166 + <p class="text-base-content">No artifacts available</p> 167 + {{ end }} 168 + </div> 169 + {{ end }} 170 + 171 + {{ define "repo-tags-page" }} 172 + {{ range .Entries }} 173 + {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }} 174 + {{ end }} 175 + {{ template "load-more-button" . }} 176 + {{ template "scan-batch-triggers" . }} 177 + {{ end }}
+53
pkg/hold/pds/imageconfig.go
··· 1 + package pds 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "atcr.io/pkg/atproto" 8 + "github.com/ipfs/go-cid" 9 + ) 10 + 11 + // CreateImageConfigRecord creates or updates an OCI image config record in the hold's PDS. 12 + // Uses a deterministic rkey based on the manifest digest, so re-pushes upsert. 13 + func (p *HoldPDS) CreateImageConfigRecord(ctx context.Context, record *atproto.ImageConfigRecord, manifestDigest string) (string, cid.Cid, error) { 14 + if record.Type != atproto.ImageConfigCollection { 15 + return "", cid.Undef, fmt.Errorf("invalid record type: %s", record.Type) 16 + } 17 + 18 + if record.Manifest == "" { 19 + return "", cid.Undef, fmt.Errorf("manifest AT-URI is required") 20 + } 21 + 22 + rkey := atproto.ScanRecordKey(manifestDigest) 23 + 24 + rpath, recordCID, _, err := p.repomgr.UpsertRecord( 25 + ctx, 26 + p.uid, 27 + atproto.ImageConfigCollection, 28 + rkey, 29 + record, 30 + ) 31 + if err != nil { 32 + return "", cid.Undef, fmt.Errorf("failed to upsert image config record: %w", err) 33 + } 34 + 35 + return rpath, recordCID, nil 36 + } 37 + 38 + // GetImageConfigRecord retrieves an OCI image config record by manifest digest. 39 + func (p *HoldPDS) GetImageConfigRecord(ctx context.Context, manifestDigest string) (cid.Cid, *atproto.ImageConfigRecord, error) { 40 + rkey := atproto.ScanRecordKey(manifestDigest) 41 + 42 + recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.ImageConfigCollection, rkey, cid.Undef) 43 + if err != nil { 44 + return cid.Undef, nil, fmt.Errorf("failed to get image config record: %w", err) 45 + } 46 + 47 + configRecord, ok := val.(*atproto.ImageConfigRecord) 48 + if !ok { 49 + return cid.Undef, nil, fmt.Errorf("unexpected type for image config record: %T", val) 50 + } 51 + 52 + return recordCID, configRecord, nil 53 + }