A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
72
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 + }