add new files for getting image configs from hold etc

This commit is contained in:
Evan Jarrett
2026-03-22 21:17:28 -05:00
parent 385f8987fe
commit d6816fd00e
9 changed files with 932 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
{
"lexicon": 1,
"id": "io.atcr.hold.getLayersForManifest",
"defs": {
"main": {
"type": "query",
"description": "Returns layer records for a specific manifest AT-URI.",
"parameters": {
"type": "params",
"required": ["manifest"],
"properties": {
"manifest": {
"type": "string",
"format": "at-uri",
"description": "AT-URI of the manifest to get layers for"
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["layers"],
"properties": {
"layers": {
"type": "array",
"items": {
"type": "ref",
"ref": "io.atcr.hold.layer"
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,32 @@
{
"lexicon": 1,
"id": "io.atcr.hold.image.config",
"defs": {
"main": {
"type": "record",
"key": "any",
"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.",
"record": {
"type": "object",
"required": ["manifest", "configJson", "createdAt"],
"properties": {
"manifest": {
"type": "string",
"format": "at-uri",
"description": "AT-URI of the manifest this config belongs to"
},
"configJson": {
"type": "string",
"description": "Raw OCI image config JSON blob",
"maxLength": 65536
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "RFC3339 timestamp of when the config was stored"
}
}
}
}
}
}

View File

@@ -0,0 +1,28 @@
{
"lexicon": 1,
"id": "io.atcr.hold.image.getConfig",
"defs": {
"main": {
"type": "query",
"description": "Returns the OCI image config record for a specific manifest digest.",
"parameters": {
"type": "params",
"required": ["digest"],
"properties": {
"digest": {
"type": "string",
"description": "Manifest digest (e.g., sha256:abc123...)",
"maxLength": 128
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "ref",
"ref": "io.atcr.hold.image.config"
}
}
}
}
}

View File

@@ -0,0 +1,178 @@
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
}
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] + "..."
}

View File

@@ -0,0 +1,143 @@
package handlers
import (
"testing"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdclient"
)
func TestBuildLayerDetails_NoHistory(t *testing.T) {
dbLayers := []db.Layer{
{Digest: "sha256:aaa", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0},
{Digest: "sha256:bbb", Size: 2048, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1},
}
details := buildLayerDetails(nil, dbLayers)
if len(details) != 2 {
t.Fatalf("Expected 2 layer details, got %d", len(details))
}
if details[0].Command != "" {
t.Errorf("Expected empty command, got %q", details[0].Command)
}
if details[0].Digest != "sha256:aaa" {
t.Errorf("Expected digest sha256:aaa, got %q", details[0].Digest)
}
if details[0].Index != 1 {
t.Errorf("Expected Index 1, got %d", details[0].Index)
}
}
func TestBuildLayerDetails_WithHistory(t *testing.T) {
history := []holdclient.OCIHistoryEntry{
{CreatedBy: "ENV PATH=/usr/local/bin", EmptyLayer: true},
{CreatedBy: "RUN apt-get install curl", EmptyLayer: false},
{CreatedBy: "COPY . /app", EmptyLayer: false},
}
dbLayers := []db.Layer{
{Digest: "sha256:aaa", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0},
{Digest: "sha256:bbb", Size: 2048, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1},
}
details := buildLayerDetails(history, dbLayers)
if len(details) != 3 {
t.Fatalf("Expected 3 details (1 empty + 2 real), got %d", len(details))
}
// First entry is empty layer (ENV)
if !details[0].EmptyLayer {
t.Error("Expected first detail to be empty layer")
}
if details[0].Command != "ENV PATH=/usr/local/bin" {
t.Errorf("Expected ENV command, got %q", details[0].Command)
}
if details[0].Digest != "" {
t.Errorf("Expected empty digest for empty layer, got %q", details[0].Digest)
}
// Second entry is real layer with digest
if details[1].EmptyLayer {
t.Error("Expected second detail to not be empty layer")
}
if details[1].Digest != "sha256:aaa" {
t.Errorf("Expected digest sha256:aaa, got %q", details[1].Digest)
}
if details[1].Command != "RUN apt-get install curl" {
t.Errorf("Expected RUN command, got %q", details[1].Command)
}
// Third entry is real layer
if details[2].Digest != "sha256:bbb" {
t.Errorf("Expected digest sha256:bbb, got %q", details[2].Digest)
}
if details[2].Command != "COPY . /app" {
t.Errorf("Expected COPY command, got %q", details[2].Command)
}
}
func TestBuildLayerDetails_DistrolessWithUserLayers(t *testing.T) {
// Simulates distroless base (many empty layers) + user COPY layers
history := []holdclient.OCIHistoryEntry{
{CreatedBy: "bazel build ...", EmptyLayer: false}, // distroless base layer
{CreatedBy: "LABEL maintainer=distroless", EmptyLayer: true}, // metadata
{CreatedBy: "ENV SSL_CERT_DIR=/etc/ssl/certs", EmptyLayer: true},
{CreatedBy: "COPY /app /app # buildkit", EmptyLayer: false}, // user layer
}
dbLayers := []db.Layer{
{Digest: "sha256:base", Size: 5000000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0},
{Digest: "sha256:user", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1},
}
details := buildLayerDetails(history, dbLayers)
if len(details) != 4 {
t.Fatalf("Expected 4 details (2 real + 2 empty), got %d", len(details))
}
// Real layer
if details[0].EmptyLayer || details[0].Digest != "sha256:base" {
t.Errorf("Layer 0: expected real layer with sha256:base, got empty=%v digest=%q", details[0].EmptyLayer, details[0].Digest)
}
// Empty layers
if !details[1].EmptyLayer || details[1].Command != "LABEL maintainer=distroless" {
t.Errorf("Layer 1: expected empty LABEL layer")
}
if !details[2].EmptyLayer || details[2].Command != "ENV SSL_CERT_DIR=/etc/ssl/certs" {
t.Errorf("Layer 2: expected empty ENV layer")
}
// User layer
if details[3].EmptyLayer || details[3].Digest != "sha256:user" {
t.Errorf("Layer 3: expected real layer with sha256:user, got empty=%v digest=%q", details[3].EmptyLayer, details[3].Digest)
}
if details[3].Command != "COPY /app /app # buildkit" {
t.Errorf("Layer 3: Command = %q, want COPY command", details[3].Command)
}
}
func TestBuildLayerDetails_AllEmptyLayers(t *testing.T) {
// Edge case: all history entries are empty layers (scratch-based with only metadata)
history := []holdclient.OCIHistoryEntry{
{CreatedBy: "ENV FOO=bar", EmptyLayer: true},
{CreatedBy: "LABEL version=1.0", EmptyLayer: true},
}
details := buildLayerDetails(history, nil)
if len(details) != 2 {
t.Fatalf("Expected 2 details, got %d", len(details))
}
for i, d := range details {
if !d.EmptyLayer {
t.Errorf("Layer %d: expected empty layer", i)
}
if d.Digest != "" {
t.Errorf("Layer %d: expected empty digest, got %q", i, d.Digest)
}
}
}

View File

@@ -0,0 +1,69 @@
package holdclient
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"atcr.io/pkg/atproto"
)
// OCIHistoryEntry represents a single entry in the OCI config history.
type OCIHistoryEntry struct {
Created string `json:"created"`
CreatedBy string `json:"created_by"`
EmptyLayer bool `json:"empty_layer"`
Comment string `json:"comment"`
}
// OCIConfig represents the parsed OCI image config with fields useful for display.
type OCIConfig struct {
History []OCIHistoryEntry `json:"history"`
}
// FetchImageConfig fetches the OCI image config record from the hold's
// getImageConfig XRPC endpoint. holdURL should be a resolved HTTP(S) URL.
// Returns the parsed OCI config with history entries.
func FetchImageConfig(ctx context.Context, holdURL, manifestDigest string) (*OCIConfig, error) {
reqURL := fmt.Sprintf("%s%s?digest=%s",
strings.TrimSuffix(holdURL, "/"),
atproto.HoldGetImageConfig,
url.QueryEscape(manifestDigest),
)
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch image config: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("hold returned status %d for %s", resp.StatusCode, reqURL)
}
var record struct {
ConfigJSON string `json:"configJson"`
}
if err := json.NewDecoder(resp.Body).Decode(&record); err != nil {
return nil, fmt.Errorf("parse image config response: %w", err)
}
var config OCIConfig
if err := json.Unmarshal([]byte(record.ConfigJSON), &config); err != nil {
return nil, fmt.Errorf("parse OCI config JSON: %w", err)
}
return &config, nil
}

View File

@@ -0,0 +1,215 @@
{{ define "digest" }}
<!DOCTYPE html>
<html lang="en">
<head>
{{ template "head" . }}
{{ template "meta" .Meta }}
</head>
<body>
{{ template "nav" . }}
<main class="container mx-auto px-4 py-8">
<div class="space-y-6">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a></li>
<li><a href="/r/{{ .Owner.Handle }}/{{ .Repository }}" class="link link-primary">{{ .Repository }}</a></li>
<li><code class="font-mono text-xs">{{ truncateDigest .Manifest.Digest 24 }}</code></li>
</ul>
</div>
<!-- Digest Header -->
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-2">
<h1 class="text-xl font-bold font-mono break-all">{{ .Manifest.Digest }}</h1>
<div class="flex flex-wrap items-center gap-2">
{{ if .Manifest.Tags }}
{{ range .Manifest.Tags }}
<span class="badge badge-md badge-primary">{{ . }}</span>
{{ end }}
{{ end }}
{{ if .Manifest.IsManifestList }}
<span class="badge badge-md badge-soft badge-accent">Multi-arch</span>
{{ else if eq .Manifest.ArtifactType "helm-chart" }}
<span class="badge badge-md badge-soft badge-helm">{{ icon "helm" "size-3" }} Helm</span>
{{ end }}
{{ if .Manifest.HasAttestations }}
<span class="badge badge-md badge-soft badge-success">{{ icon "shield-check" "size-3" }} Attested</span>
{{ end }}
</div>
</div>
<div class="flex items-center gap-2">
<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>
<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('{{ .Manifest.Digest }}')" aria-label="Copy digest">{{ icon "copy" "size-4" }}</button>
</div>
</div>
</div>
{{ if .Manifest.IsManifestList }}
<!-- Platform Picker for Manifest Lists -->
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4">
<h2 class="text-lg font-semibold">Platforms</h2>
<p class="text-sm">This is a multi-architecture manifest list. Select a platform to view layers and vulnerabilities.</p>
<div class="space-y-2">
{{ range .Manifest.Platforms }}
<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">
<div class="flex items-center gap-3">
<span class="font-medium">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
<code class="font-mono text-xs text-base-content" title="{{ .Digest }}">{{ truncateDigest .Digest 32 }}</code>
</div>
<div class="flex items-center gap-3">
{{ if .CompressedSize }}
<span class="text-sm">{{ humanizeBytes .CompressedSize }}</span>
{{ end }}
{{ icon "chevron-right" "size-4" }}
</div>
</a>
{{ end }}
</div>
</div>
{{ else }}
<!-- Layers + Vulnerabilities Side by Side -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Layers (Left) -->
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Layers ({{ len .Layers }})</h2>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" class="checkbox checkbox-xs" id="show-empty-layers" onchange="toggleEmptyLayers(this.checked)">
<span>Show empty layers</span>
</label>
</div>
{{ if .Layers }}
<div class="overflow-x-auto">
<table class="table table-xs w-full" id="layers-table">
<thead>
<tr class="text-xs">
<th class="w-8">#</th>
<th>Command</th>
<th class="text-right w-24">Size</th>
</tr>
</thead>
<tbody>
{{ range .Layers }}
<tr data-empty="{{ .EmptyLayer }}" data-no-command="{{ and (not .Command) (not .EmptyLayer) }}">
<td class="font-mono text-xs">{{ .Index }}</td>
<td>
{{ if .Command }}
<code class="font-mono text-xs break-all line-clamp-2" title="{{ .Command }}">{{ .Command }}</code>
{{ end }}
</td>
<td class="text-right text-sm whitespace-nowrap">{{ humanizeBytes .Size }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
<script>
(function() {
// Toggle empty layers (ENV, LABEL, ENTRYPOINT, etc.)
var showEmpty = localStorage.getItem('showEmptyLayers') === 'true';
var checkbox = document.getElementById('show-empty-layers');
if (checkbox) checkbox.checked = showEmpty;
window.toggleEmptyLayers = function(show) {
localStorage.setItem('showEmptyLayers', show);
applyLayerVisibility();
};
// Collapse consecutive no-command, non-empty layers (distroless base)
function collapseNoHistoryLayers() {
var tbody = document.querySelector('#layers-table tbody');
if (!tbody) return;
var rows = Array.from(tbody.querySelectorAll('tr'));
var i = 0;
while (i < rows.length) {
if (rows[i].dataset.noCommand === 'true') {
// Find consecutive no-command rows
var start = i;
while (i < rows.length && rows[i].dataset.noCommand === 'true') {
rows[i].classList.add('no-history-row', 'hidden');
i++;
}
var count = i - start;
if (count > 1) {
// Sum sizes from the collapsed rows
var totalBytes = 0;
for (var k = start; k < start + count; k++) {
var sizeCell = rows[k].querySelector('td:last-child');
if (sizeCell) {
var txt = sizeCell.textContent.trim();
var match = txt.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
if (match) {
var val = parseFloat(match[1]);
var unit = match[2].toUpperCase();
var multipliers = {'B':1,'KB':1024,'MB':1048576,'GB':1073741824,'TB':1099511627776};
totalBytes += val * (multipliers[unit] || 1);
}
}
}
var sizeStr = '';
if (totalBytes < 1024) sizeStr = totalBytes + ' B';
else if (totalBytes < 1048576) sizeStr = (totalBytes/1024).toFixed(1) + ' KB';
else if (totalBytes < 1073741824) sizeStr = (totalBytes/1048576).toFixed(1) + ' MB';
else sizeStr = (totalBytes/1073741824).toFixed(1) + ' GB';
// Insert summary row
var startIdx = rows[start].querySelector('td').textContent.trim();
var endIdx = rows[i - 1].querySelector('td').textContent.trim();
var summary = document.createElement('tr');
summary.className = 'no-history-summary cursor-pointer hover:bg-base-200';
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>';
summary.onclick = function() {
summary.remove();
for (var j = start; j < start + count; j++) {
rows[j].classList.remove('hidden');
}
};
tbody.insertBefore(summary, rows[start]);
} else {
// Single no-command row, just show it
rows[start].classList.remove('hidden');
}
} else {
i++;
}
}
}
function applyLayerVisibility() {
var show = localStorage.getItem('showEmptyLayers') === 'true';
document.querySelectorAll('#layers-table tr[data-empty="true"]').forEach(function(row) {
row.style.display = show ? '' : 'none';
});
}
collapseNoHistoryLayers();
applyLayerVisibility();
})();
</script>
{{ else }}
<p class="text-base-content">No layer information available</p>
{{ end }}
</div>
<!-- Vulnerabilities (Right) -->
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
<h2 class="text-lg font-semibold">Vulnerabilities</h2>
{{ if .VulnData }}
{{ template "vuln-details" .VulnData }}
{{ else }}
<p class="text-base-content">No vulnerability scan data available</p>
{{ end }}
</div>
</div>
{{ end }}
</div>
</main>
{{ template "footer" . }}
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,177 @@
{{ define "artifact-entry-markup" }}
<div class="artifact-entry p-6" data-tag="{{ .Entry.Label }}" data-created="{{ .Entry.CreatedAt.Unix }}">
<!-- Entry Header -->
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono font-semibold{{ if not .Entry.IsTagged }} text-sm{{ end }}">{{ .Entry.Label }}</span>
{{ if eq .Entry.ArtifactType "helm-chart" }}
<span class="badge badge-xs badge-soft badge-helm">{{ icon "helm" "size-3" }} Helm</span>
{{ else if .Entry.IsMultiArch }}
<span class="badge badge-xs badge-soft badge-accent">Multi-arch</span>
{{ end }}
{{ if .Entry.HasAttestations }}
<button class="badge badge-xs badge-soft badge-success cursor-pointer hover:opacity-80"
hx-get="/api/attestation-details?digest={{ .Entry.Digest | urlquery }}&did={{ .OwnerDID | urlquery }}&repo={{ .RepoName | urlquery }}"
hx-target="#attestation-modal-body"
hx-swap="innerHTML"
onclick="document.getElementById('attestation-detail-modal').showModal()">
{{ icon "shield-check" "size-3" }} Attested
</button>
{{ end }}
</div>
<div class="flex items-center gap-2">
{{ if eq .Entry.ArtifactType "helm-chart" }}
{{ if .Entry.IsTagged }}
{{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName " --version " .Entry.Label) }}
{{ else }}
{{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }}
{{ end }}
{{ else }}
{{ if .Entry.IsTagged }}
{{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Entry.Label) }}
{{ else }}
{{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }}
{{ end }}
{{ end }}
<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>
{{ if .IsOwner }}
{{ if .Entry.IsTagged }}
<button class="btn btn-ghost btn-sm text-error"
hx-ext="json-enc"
hx-delete="/api/tags"
hx-vals='{"repo": "{{ .RepoName }}", "tag": "{{ .Entry.Label }}"}'
hx-confirm="Delete tag {{ .Entry.Label }}?"
hx-target="closest .artifact-entry"
hx-swap="outerHTML"
aria-label="Delete tag {{ .Entry.Label }}">
{{ icon "trash-2" "size-4" }}
</button>
{{ else }}
<button class="btn btn-ghost btn-sm text-error"
onclick="deleteManifest('{{ .RepoName }}', '{{ .Entry.Digest }}', '')"
aria-label="Delete manifest">
{{ icon "trash-2" "size-4" }}
</button>
{{ end }}
{{ end }}
</div>
</div>
<!-- Manifest Details Table -->
<table class="table table-xs w-full table-fixed">
<colgroup>
<col class="w-5/12">
<col class="w-3/12">
<col class="w-2/12">
<col class="w-2/12">
</colgroup>
<thead>
<tr class="text-base-content text-xs">
<th>Digest</th>
<th>Vulnerabilities</th>
<th>OS/Arch</th>
<th>Size</th>
</tr>
</thead>
<tbody>
{{ range .Entry.Platforms }}
<tr>
<td>
<div class="flex items-center gap-1">
<a href="/d/{{ $.OwnerHandle }}/{{ $.RepoName }}/{{ .Digest }}" class="font-mono text-xs link link-primary" title="{{ .Digest }}">{{ truncateDigest .Digest 32 }}</a>
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Digest }}')" aria-label="Copy digest">{{ icon "copy" "size-3" }}</button>
</div>
</td>
<td>
{{ if .HoldEndpoint }}
<a href="/d/{{ $.OwnerHandle }}/{{ $.RepoName }}/{{ .Digest }}" class="hover:opacity-80 transition-opacity">
<span id="scan-badge-{{ trimPrefix "sha256:" .Digest }}"></span>
</a>
{{ end }}
</td>
<td>
{{ if .OS }}{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}{{ else }}-{{ end }}
</td>
<td class="text-sm text-base-content">{{ if .CompressedSize }}{{ humanizeBytes .CompressedSize }}{{ else }}-{{ end }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}
{{ define "load-more-button" }}
{{ if .HasMore }}
<div id="load-more-container" class="p-6 text-center border-t border-base-200">
<button class="btn btn-outline"
hx-get="/api/repo-tags/{{ .Owner.Handle }}/{{ .Repository.Name }}?offset={{ .NextOffset }}"
hx-target="#tags-list"
hx-swap="beforeend"
hx-on::before-request="document.getElementById('load-more-container').remove()">
Load More
</button>
</div>
{{ end }}
{{ end }}
{{ define "scan-batch-triggers" }}
{{ range .ScanBatchParams }}
<div hx-get="/api/scan-results?{{ . }}"
hx-trigger="load delay:500ms"
hx-swap="none"
style="display:none"></div>
{{ end }}
{{ end }}
{{ define "repo-tags" }}
<div class="space-y-4">
{{ if .Entries }}
<!-- Filter/Sort Controls -->
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium whitespace-nowrap">Sort by</span>
<select id="tag-sort" class="select select-sm select-bordered min-w-28" onchange="sortTags(this.value)">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="az">A-Z</option>
<option value="za">Z-A</option>
</select>
</div>
<div class="flex-1 max-w-xs">
<input type="text" id="tag-filter" class="input input-sm input-bordered w-full" placeholder="Filter artifacts..." oninput="filterTags(this.value)">
</div>
{{ if $.IsOwner }}
<div class="flex-none ml-auto">
<button class="btn btn-ghost btn-sm text-error"
onclick="document.getElementById('untagged-delete-modal').showModal()"
aria-label="Delete all untagged manifests">
{{ icon "trash-2" "size-4" }} Delete untagged
</button>
</div>
{{ end }}
</div>
<!-- Manifest Entries -->
<div class="card bg-base-100 shadow-sm border border-base-300">
<div class="divide-y divide-base-200" id="tags-list">
{{ range .Entries }}
{{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }}
{{ end }}
</div>
{{ template "load-more-button" . }}
</div>
{{ template "scan-batch-triggers" . }}
{{ else }}
<p class="text-base-content">No artifacts available</p>
{{ end }}
</div>
{{ end }}
{{ define "repo-tags-page" }}
{{ range .Entries }}
{{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }}
{{ end }}
{{ template "load-more-button" . }}
{{ template "scan-batch-triggers" . }}
{{ end }}

View File

@@ -0,0 +1,53 @@
package pds
import (
"context"
"fmt"
"atcr.io/pkg/atproto"
"github.com/ipfs/go-cid"
)
// CreateImageConfigRecord creates or updates an OCI image config record in the hold's PDS.
// Uses a deterministic rkey based on the manifest digest, so re-pushes upsert.
func (p *HoldPDS) CreateImageConfigRecord(ctx context.Context, record *atproto.ImageConfigRecord, manifestDigest string) (string, cid.Cid, error) {
if record.Type != atproto.ImageConfigCollection {
return "", cid.Undef, fmt.Errorf("invalid record type: %s", record.Type)
}
if record.Manifest == "" {
return "", cid.Undef, fmt.Errorf("manifest AT-URI is required")
}
rkey := atproto.ScanRecordKey(manifestDigest)
rpath, recordCID, _, err := p.repomgr.UpsertRecord(
ctx,
p.uid,
atproto.ImageConfigCollection,
rkey,
record,
)
if err != nil {
return "", cid.Undef, fmt.Errorf("failed to upsert image config record: %w", err)
}
return rpath, recordCID, nil
}
// GetImageConfigRecord retrieves an OCI image config record by manifest digest.
func (p *HoldPDS) GetImageConfigRecord(ctx context.Context, manifestDigest string) (cid.Cid, *atproto.ImageConfigRecord, error) {
rkey := atproto.ScanRecordKey(manifestDigest)
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.ImageConfigCollection, rkey, cid.Undef)
if err != nil {
return cid.Undef, nil, fmt.Errorf("failed to get image config record: %w", err)
}
configRecord, ok := val.(*atproto.ImageConfigRecord)
if !ok {
return cid.Undef, nil, fmt.Errorf("unexpected type for image config record: %T", val)
}
return recordCID, configRecord, nil
}