mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-06-08 16:22:36 +00:00
show attestation details
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
description: Add annotations column to layers table for caching layer-level annotations (e.g. in-toto predicate types)
|
||||
query: |
|
||||
ALTER TABLE layers ADD COLUMN annotations TEXT;
|
||||
@@ -29,11 +29,12 @@ type Manifest struct {
|
||||
|
||||
// Layer represents a layer in a manifest
|
||||
type Layer struct {
|
||||
ManifestID int64
|
||||
Digest string
|
||||
Size int64
|
||||
MediaType string
|
||||
LayerIndex int
|
||||
ManifestID int64
|
||||
Digest string
|
||||
Size int64
|
||||
MediaType string
|
||||
LayerIndex int
|
||||
Annotations map[string]string // JSON-encoded layer annotations (e.g. in-toto predicate type)
|
||||
}
|
||||
|
||||
// ManifestReference represents a reference to a manifest in a manifest list/index
|
||||
@@ -149,3 +150,12 @@ type ManifestWithMetadata struct {
|
||||
Pending bool // Whether health check is still in progress
|
||||
// Note: ArtifactType is available via embedded Manifest struct
|
||||
}
|
||||
|
||||
// AttestationDetail represents an attestation manifest and its layers
|
||||
type AttestationDetail struct {
|
||||
Digest string
|
||||
MediaType string // attestation manifest media type
|
||||
Size int64
|
||||
HoldEndpoint string // hold DID/URL where blobs are stored
|
||||
Layers []Layer
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -576,12 +577,27 @@ func InsertManifest(db DBTX, manifest *Manifest) (int64, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// InsertLayer inserts a new layer record
|
||||
// InsertLayer inserts or updates a layer record.
|
||||
// Uses upsert so backfill re-processing populates new columns (e.g. annotations).
|
||||
func InsertLayer(db DBTX, layer *Layer) error {
|
||||
var annotationsJSON *string
|
||||
if len(layer.Annotations) > 0 {
|
||||
b, err := json.Marshal(layer.Annotations)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal layer annotations: %w", err)
|
||||
}
|
||||
s := string(b)
|
||||
annotationsJSON = &s
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO layers (manifest_id, digest, size, media_type, layer_index)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex)
|
||||
INSERT INTO layers (manifest_id, digest, size, media_type, layer_index, annotations)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(manifest_id, layer_index) DO UPDATE SET
|
||||
digest = excluded.digest,
|
||||
size = excluded.size,
|
||||
media_type = excluded.media_type,
|
||||
annotations = excluded.annotations
|
||||
`, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex, annotationsJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -820,7 +836,7 @@ func GetRepositoriesForDID(db DBTX, did string) ([]string, error) {
|
||||
// GetLayersForManifest fetches all layers for a manifest
|
||||
func GetLayersForManifest(db DBTX, manifestID int64) ([]Layer, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT manifest_id, digest, size, media_type, layer_index
|
||||
SELECT manifest_id, digest, size, media_type, layer_index, annotations
|
||||
FROM layers
|
||||
WHERE manifest_id = ?
|
||||
ORDER BY layer_index
|
||||
@@ -834,9 +850,15 @@ func GetLayersForManifest(db DBTX, manifestID int64) ([]Layer, error) {
|
||||
var layers []Layer
|
||||
for rows.Next() {
|
||||
var l Layer
|
||||
if err := rows.Scan(&l.ManifestID, &l.Digest, &l.Size, &l.MediaType, &l.LayerIndex); err != nil {
|
||||
var annotationsJSON sql.NullString
|
||||
if err := rows.Scan(&l.ManifestID, &l.Digest, &l.Size, &l.MediaType, &l.LayerIndex, &annotationsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if annotationsJSON.Valid && annotationsJSON.String != "" {
|
||||
if err := json.Unmarshal([]byte(annotationsJSON.String), &l.Annotations); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal layer annotations: %w", err)
|
||||
}
|
||||
}
|
||||
layers = append(layers, l)
|
||||
}
|
||||
|
||||
@@ -1209,6 +1231,78 @@ func GetManifestTags(db DBTX, did, repository, digest string) ([]string, error)
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// GetAttestationDetails returns attestation manifests and their layers for a manifest list.
|
||||
// Joins manifest_references (is_attestation=true) → manifests → layers.
|
||||
func GetAttestationDetails(db DBTX, did, repository, manifestListDigest string) ([]AttestationDetail, error) {
|
||||
// Step 1: Get the manifest list ID and hold endpoint
|
||||
var manifestListID int64
|
||||
var parentHoldEndpoint string
|
||||
err := db.QueryRow(`
|
||||
SELECT id, hold_endpoint FROM manifests
|
||||
WHERE did = ? AND repository = ? AND digest = ?
|
||||
`, did, repository, manifestListDigest).Scan(&manifestListID, &parentHoldEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 2: Get attestation references and join to their manifest records
|
||||
rows, err := db.Query(`
|
||||
SELECT mr.digest, mr.media_type, mr.size, m.id
|
||||
FROM manifest_references mr
|
||||
LEFT JOIN manifests m ON m.digest = mr.digest AND m.did = ? AND m.repository = ?
|
||||
WHERE mr.manifest_id = ? AND mr.is_attestation = 1
|
||||
ORDER BY mr.reference_index
|
||||
`, did, repository, manifestListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type refRow struct {
|
||||
digest string
|
||||
mediaType string
|
||||
size int64
|
||||
manifestID *int64 // may be NULL if attestation manifest not indexed yet
|
||||
}
|
||||
var refs []refRow
|
||||
for rows.Next() {
|
||||
var r refRow
|
||||
var mid sql.NullInt64
|
||||
if err := rows.Scan(&r.digest, &r.mediaType, &r.size, &mid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mid.Valid {
|
||||
r.manifestID = &mid.Int64
|
||||
}
|
||||
refs = append(refs, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 3: For each attestation manifest, fetch its layers
|
||||
// Use the parent manifest list's hold endpoint — attestation blobs are in the same hold
|
||||
details := make([]AttestationDetail, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
detail := AttestationDetail{
|
||||
Digest: ref.digest,
|
||||
MediaType: ref.mediaType,
|
||||
Size: ref.size,
|
||||
HoldEndpoint: parentHoldEndpoint,
|
||||
}
|
||||
if ref.manifestID != nil {
|
||||
layers, err := GetLayersForManifest(db, *ref.manifestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail.Layers = layers
|
||||
}
|
||||
details = append(details, detail)
|
||||
}
|
||||
|
||||
return details, nil
|
||||
}
|
||||
|
||||
// BackfillState represents the backfill progress
|
||||
type BackfillState struct {
|
||||
StartCursor int64
|
||||
|
||||
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS layers (
|
||||
size INTEGER NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
layer_index INTEGER NOT NULL,
|
||||
annotations TEXT,
|
||||
PRIMARY KEY(manifest_id, layer_index),
|
||||
FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
334
pkg/appview/handlers/attestation_details.go
Normal file
334
pkg/appview/handlers/attestation_details.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
)
|
||||
|
||||
// AttestationDetailsHandler handles requests for attestation detail modal content.
|
||||
// Returns an HTML fragment (attestation-details partial) for insertion into the modal body.
|
||||
type AttestationDetailsHandler struct {
|
||||
BaseUIHandler
|
||||
}
|
||||
|
||||
// attestationDetailsData is the template data for the attestation-details partial.
|
||||
type attestationDetailsData struct {
|
||||
Attestations []attestationInfo
|
||||
Error string
|
||||
LoginURL string // login URL with return_to for the current repo page
|
||||
}
|
||||
|
||||
type attestationInfo struct {
|
||||
Digest string
|
||||
PredicateType string // human-friendly predicate type name
|
||||
RawJSON string // formatted JSON content of the attestation (empty for binary)
|
||||
MediaType string // layer media type (shown when content is not displayable)
|
||||
Size int64 // layer size in bytes
|
||||
FetchError string // non-empty if blob fetch failed
|
||||
NeedsLogin bool // true if blob fetch failed due to auth (403)
|
||||
}
|
||||
|
||||
// predicateTypePrefixes maps in-toto predicate type URI prefixes to human-friendly names.
|
||||
// Prefix matching handles version drift (e.g. v1 → v1.1) without code changes.
|
||||
// Source: https://github.com/in-toto/attestation/tree/main/spec/predicates
|
||||
var predicateTypePrefixes = []struct {
|
||||
prefix string
|
||||
name string
|
||||
}{
|
||||
{"https://slsa.dev/provenance/", "SLSA Provenance"},
|
||||
{"https://slsa.dev/verification_summary/", "SLSA Verification Summary"},
|
||||
{"https://spdx.dev/Document", "SPDX SBOM"},
|
||||
{"https://cyclonedx.org/bom", "CycloneDX SBOM"},
|
||||
{"https://in-toto.io/attestation/vulns", "Vulnerability Scan"},
|
||||
{"https://in-toto.io/attestation/scai/", "SCAI Report"},
|
||||
{"https://in-toto.io/attestation/link/", "in-toto Link"},
|
||||
{"https://in-toto.io/attestation/runtime-trace/", "Runtime Trace"},
|
||||
{"https://in-toto.io/attestation/release", "Release"},
|
||||
{"https://in-toto.io/attestation/test-result/", "Test Result"},
|
||||
{"https://in-toto.io/attestation/reference/", "Reference"},
|
||||
}
|
||||
|
||||
func friendlyPredicateType(predicateType string) string {
|
||||
for _, p := range predicateTypePrefixes {
|
||||
if strings.HasPrefix(predicateType, p.prefix) {
|
||||
return p.name
|
||||
}
|
||||
}
|
||||
if predicateType != "" {
|
||||
return predicateType
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// inTotoStatement is the minimal in-toto statement structure for extracting the predicate type.
|
||||
type inTotoStatement struct {
|
||||
Type string `json:"_type"`
|
||||
PredicateType string `json:"predicateType"`
|
||||
Subject json.RawMessage `json:"subject"`
|
||||
Predicate json.RawMessage `json:"predicate"`
|
||||
}
|
||||
|
||||
func (h *AttestationDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
digest := r.URL.Query().Get("digest")
|
||||
did := r.URL.Query().Get("did")
|
||||
repo := r.URL.Query().Get("repo")
|
||||
|
||||
if digest == "" || did == "" || repo == "" {
|
||||
h.renderDetails(w, attestationDetailsData{Error: "Missing required parameters"})
|
||||
return
|
||||
}
|
||||
|
||||
details, err := db.GetAttestationDetails(h.ReadOnlyDB, did, repo, digest)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch attestation details", "error", err, "digest", digest)
|
||||
h.renderDetails(w, attestationDetailsData{Error: "No attestation details found"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(details) == 0 {
|
||||
h.renderDetails(w, attestationDetailsData{Error: "No attestations found for this manifest"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Look up repo owner for PDS endpoint and handle
|
||||
var pdsEndpoint string
|
||||
var loginURL string
|
||||
if repoOwner, err := db.GetUserByDID(h.ReadOnlyDB, did); err == nil && repoOwner != nil {
|
||||
pdsEndpoint = repoOwner.PDSEndpoint
|
||||
returnTo := fmt.Sprintf("/r/%s/%s", repoOwner.Handle, repo)
|
||||
loginURL = "/auth/oauth/login?return_to=" + url.QueryEscape(returnTo)
|
||||
}
|
||||
|
||||
// If the viewer is logged in, get a service token so we can read from private holds
|
||||
var serviceToken string
|
||||
loggedIn := false
|
||||
if user := middleware.GetUser(r); user != nil && h.Refresher != nil {
|
||||
loggedIn = true
|
||||
holdDID := atproto.ResolveHoldDIDFromURL(details[0].HoldEndpoint)
|
||||
if holdDID == "" {
|
||||
holdDID = details[0].HoldEndpoint // might already be a DID
|
||||
}
|
||||
if token, err := auth.GetOrFetchServiceToken(ctx, h.Refresher, user.DID, holdDID, user.PDSEndpoint); err == nil {
|
||||
serviceToken = token
|
||||
} else {
|
||||
slog.Debug("Could not get service token for attestation fetch", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
attestations := make([]attestationInfo, 0, len(details))
|
||||
for _, d := range details {
|
||||
info := attestationInfo{
|
||||
Digest: d.Digest,
|
||||
}
|
||||
|
||||
// Resolve layer digest and annotations — prefer local DB, fallback to PDS
|
||||
layerDigest := ""
|
||||
if len(d.Layers) > 0 {
|
||||
layerDigest = d.Layers[0].Digest
|
||||
// Check local DB annotations for predicate type
|
||||
if pt, ok := d.Layers[0].Annotations["in-toto.io/predicate-type"]; ok {
|
||||
info.PredicateType = friendlyPredicateType(pt)
|
||||
} else if pdsEndpoint != "" {
|
||||
// Annotations not cached locally (pre-migration data) — fetch from PDS
|
||||
pdsLayers, err := fetchLayersFromPDS(ctx, pdsEndpoint, did, d.Digest)
|
||||
if err != nil {
|
||||
slog.Debug("Failed to fetch annotations from PDS for pre-migration layer", "error", err, "digest", d.Digest)
|
||||
} else if len(pdsLayers) > 0 {
|
||||
if pt, ok := pdsLayers[0].Annotations["in-toto.io/predicate-type"]; ok {
|
||||
info.PredicateType = friendlyPredicateType(pt)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if pdsEndpoint != "" {
|
||||
// Fallback: layers not in local DB yet — fetch from PDS
|
||||
pdsLayers, err := fetchLayersFromPDS(ctx, pdsEndpoint, did, d.Digest)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch attestation manifest from PDS", "error", err, "digest", d.Digest)
|
||||
} else if len(pdsLayers) > 0 {
|
||||
layerDigest = pdsLayers[0].Digest
|
||||
if pt, ok := pdsLayers[0].Annotations["in-toto.io/predicate-type"]; ok {
|
||||
info.PredicateType = friendlyPredicateType(pt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if layerDigest != "" && d.HoldEndpoint != "" {
|
||||
content, err := fetchLayerBlob(ctx, d.HoldEndpoint, layerDigest, serviceToken)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch attestation blob", "error", err, "digest", layerDigest)
|
||||
if !loggedIn && strings.Contains(err.Error(), "403") {
|
||||
info.NeedsLogin = true
|
||||
} else {
|
||||
info.FetchError = "Could not fetch attestation content"
|
||||
}
|
||||
if info.PredicateType == "" {
|
||||
info.PredicateType = "Unknown"
|
||||
}
|
||||
} else {
|
||||
// Check if content is valid JSON before trying to display it
|
||||
var parsed json.RawMessage
|
||||
if json.Unmarshal(content, &parsed) == nil {
|
||||
// It's JSON — parse in-toto statement for predicate type (if not already set from annotations)
|
||||
if info.PredicateType == "" {
|
||||
var stmt inTotoStatement
|
||||
if err := json.Unmarshal(content, &stmt); err == nil {
|
||||
info.PredicateType = friendlyPredicateType(stmt.PredicateType)
|
||||
} else {
|
||||
info.PredicateType = "Unknown"
|
||||
}
|
||||
}
|
||||
// Pretty-print for display
|
||||
if pretty, err := json.MarshalIndent(parsed, "", " "); err == nil {
|
||||
info.RawJSON = string(pretty)
|
||||
} else {
|
||||
info.RawJSON = string(content)
|
||||
}
|
||||
} else {
|
||||
// Binary content — show media type and size instead
|
||||
if info.PredicateType == "" {
|
||||
info.PredicateType = "Binary Attachment"
|
||||
}
|
||||
info.Size = int64(len(content))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if info.PredicateType == "" {
|
||||
info.PredicateType = "Unknown"
|
||||
}
|
||||
info.FetchError = "Attestation content not available"
|
||||
}
|
||||
|
||||
attestations = append(attestations, info)
|
||||
}
|
||||
|
||||
h.renderDetails(w, attestationDetailsData{Attestations: attestations, LoginURL: loginURL})
|
||||
}
|
||||
|
||||
// fetchLayersFromPDS fetches an attestation manifest record from the user's PDS
|
||||
// and returns its layers. Used as a fallback when layers aren't in the local DB.
|
||||
func fetchLayersFromPDS(ctx context.Context, pdsEndpoint, did, attestationDigest string) ([]atproto.BlobReference, error) {
|
||||
rkey := strings.TrimPrefix(attestationDigest, "sha256:")
|
||||
|
||||
getRecordURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
|
||||
pdsEndpoint,
|
||||
url.QueryEscape(did),
|
||||
url.QueryEscape(atproto.ManifestCollection),
|
||||
url.QueryEscape(rkey),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", getRecordURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build PDS request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PDS request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil // record doesn't exist
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("PDS returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Value atproto.ManifestRecord `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("parse PDS response: %w", err)
|
||||
}
|
||||
|
||||
return result.Value.Layers, nil
|
||||
}
|
||||
|
||||
// fetchLayerBlob fetches a small OCI layer blob from a hold service.
|
||||
// Two-hop flow: (1) get presigned URL from hold, (2) fetch blob from S3.
|
||||
// serviceToken is optional — pass "" for public holds.
|
||||
func fetchLayerBlob(ctx context.Context, holdEndpoint, layerDigest, serviceToken string) ([]byte, error) {
|
||||
holdURL := atproto.ResolveHoldURL(holdEndpoint)
|
||||
holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint)
|
||||
if holdURL == "" || holdDID == "" {
|
||||
return nil, fmt.Errorf("could not resolve hold endpoint: %s", holdEndpoint)
|
||||
}
|
||||
|
||||
// Step 1: Request presigned URL from hold
|
||||
getBlobURL := fmt.Sprintf("%s%s?did=%s&cid=%s",
|
||||
holdURL,
|
||||
atproto.SyncGetBlob,
|
||||
url.QueryEscape(holdDID),
|
||||
url.QueryEscape(layerDigest),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", getBlobURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if serviceToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hold request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("hold returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var presigned struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&presigned); err != nil {
|
||||
return nil, fmt.Errorf("parse presigned response: %w", err)
|
||||
}
|
||||
|
||||
if presigned.URL == "" {
|
||||
return nil, fmt.Errorf("empty presigned URL")
|
||||
}
|
||||
|
||||
// Step 2: Fetch blob from S3
|
||||
s3Req, err := http.NewRequestWithContext(ctx, "GET", presigned.URL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build S3 request: %w", err)
|
||||
}
|
||||
|
||||
s3Resp, err := http.DefaultClient.Do(s3Req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("S3 request: %w", err)
|
||||
}
|
||||
defer s3Resp.Body.Close()
|
||||
|
||||
if s3Resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("S3 returned status %d", s3Resp.StatusCode)
|
||||
}
|
||||
|
||||
// Limit read to 1MB to avoid loading huge blobs
|
||||
return io.ReadAll(io.LimitReader(s3Resp.Body, 1<<20))
|
||||
}
|
||||
|
||||
func (h *AttestationDetailsHandler) renderDetails(w http.ResponseWriter, data attestationDetailsData) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.Templates.ExecuteTemplate(w, "attestation-details", data); err != nil {
|
||||
slog.Warn("Failed to render attestation details", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -364,11 +364,12 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
|
||||
// Insert layers (for image manifests)
|
||||
for i, layer := range manifestRecord.Layers {
|
||||
if err := db.InsertLayer(p.db, &db.Layer{
|
||||
ManifestID: manifestID,
|
||||
Digest: layer.Digest,
|
||||
MediaType: layer.MediaType,
|
||||
Size: layer.Size,
|
||||
LayerIndex: i,
|
||||
ManifestID: manifestID,
|
||||
Digest: layer.Digest,
|
||||
MediaType: layer.MediaType,
|
||||
Size: layer.Size,
|
||||
LayerIndex: i,
|
||||
Annotations: layer.Annotations,
|
||||
}); err != nil {
|
||||
// Continue on error - layer might already exist
|
||||
continue
|
||||
|
||||
@@ -126,6 +126,11 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
router.Get("/api/scan-result", (&uihandlers.ScanResultHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
router.Get("/api/vuln-details", (&uihandlers.VulnDetailsHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
|
||||
// Attestation details API endpoint (HTMX modal content)
|
||||
router.Get("/api/attestation-details", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
|
||||
&uihandlers.AttestationDetailsHandler{BaseUIHandler: base},
|
||||
).ServeHTTP)
|
||||
|
||||
router.Get("/u/{handle}", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
|
||||
&uihandlers.UserPageHandler{BaseUIHandler: base},
|
||||
).ServeHTTP)
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
COMMAND / CODE DISPLAY
|
||||
---------------------------------------- */
|
||||
.cmd {
|
||||
@apply flex items-center gap-2 relative w-full overflow-hidden;
|
||||
@apply flex items-center gap-2 relative w-full max-w-lg overflow-hidden;
|
||||
@apply bg-base-200 border border-base-300 rounded-md;
|
||||
@apply px-3 py-2;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="cmd group">
|
||||
{{ icon "terminal" "size-4 shrink-0 text-base-content/60" }}
|
||||
<code>{{ . }}</code>
|
||||
<button class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity" data-cmd="{{ . }}" aria-label="Copy command to clipboard">
|
||||
<button class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity" data-cmd="{{ . }}" aria-label="Copy command to clipboard">
|
||||
{{ icon "copy" "size-4" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,13 @@
|
||||
<span class="badge badge-md badge-soft badge-accent">Multi-arch</span>
|
||||
{{ end }}
|
||||
{{ if .HasAttestations }}
|
||||
<span class="badge badge-md badge-soft badge-success">{{ icon "shield-check" "size-3" }} Attestations</span>
|
||||
<button class="badge badge-md badge-soft badge-success cursor-pointer hover:opacity-80"
|
||||
hx-get="/api/attestation-details?digest={{ .Tag.Digest | urlquery }}&did={{ $.Owner.DID | urlquery }}&repo={{ $.Repository.Name | urlquery }}"
|
||||
hx-target="#attestation-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('attestation-detail-modal').showModal()">
|
||||
{{ icon "shield-check" "size-3" }} Attestations
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -196,7 +202,13 @@
|
||||
<span class="flex items-center gap-1 font-medium">{{ icon "box" "size-5" }} Image</span>
|
||||
{{ end }}
|
||||
{{ if .HasAttestations }}
|
||||
<span class="badge badge-md badge-soft badge-success">{{ icon "shield-check" "size-3" }} Attestations</span>
|
||||
<button class="badge badge-md badge-soft badge-success cursor-pointer hover:opacity-80"
|
||||
hx-get="/api/attestation-details?digest={{ .Manifest.Digest | urlquery }}&did={{ $.Owner.DID | urlquery }}&repo={{ $.Repository.Name | urlquery }}"
|
||||
hx-target="#attestation-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('attestation-detail-modal').showModal()">
|
||||
{{ icon "shield-check" "size-3" }} Attestations
|
||||
</button>
|
||||
{{ end }}
|
||||
{{ if .Pending }}
|
||||
<span class="badge badge-sm badge-info"
|
||||
@@ -304,6 +316,20 @@
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
<!-- Attestation Details Modal -->
|
||||
<dialog id="attestation-detail-modal" class="modal">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold">Attestation Details</h3>
|
||||
<div id="attestation-modal-body" class="py-4">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<form method="dialog"><button class="btn">Close</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
32
pkg/appview/templates/partials/attestation-details.html
Normal file
32
pkg/appview/templates/partials/attestation-details.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{ define "attestation-details" }}
|
||||
{{ if .Error }}
|
||||
<p class="text-base-content/60">{{ .Error }}</p>
|
||||
{{ else }}
|
||||
<div class="space-y-4">
|
||||
<p class="font-semibold text-sm">{{ len .Attestations }} attestation{{ if gt (len .Attestations) 1 }}s{{ end }} attached</p>
|
||||
|
||||
{{ range .Attestations }}
|
||||
<div class="bg-base-200 rounded-lg p-4 space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="badge badge-md badge-soft badge-success">{{ .PredicateType }}</span>
|
||||
<code class="font-mono text-xs text-base-content/60 truncate max-w-48" title="{{ .Digest }}">{{ .Digest }}</code>
|
||||
</div>
|
||||
{{ if .NeedsLogin }}
|
||||
<p class="text-sm text-base-content/50"><a href="{{ $.LoginURL }}" class="link link-primary">Log in</a> to view attestation content</p>
|
||||
{{ else if .FetchError }}
|
||||
<p class="text-sm text-base-content/50">{{ .FetchError }}</p>
|
||||
{{ else if .RawJSON }}
|
||||
<details>
|
||||
<summary class="cursor-pointer text-sm text-base-content/70 hover:text-base-content">View content</summary>
|
||||
<div class="mt-2 overflow-x-auto max-h-64">
|
||||
<pre class="text-xs bg-base-300 rounded p-3 whitespace-pre-wrap break-all"><code>{{ .RawJSON }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
{{ else if .Size }}
|
||||
<p class="text-sm text-base-content/50">Binary content ({{ .Size }} bytes) — cannot display inline</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@@ -15,6 +15,27 @@ import (
|
||||
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
)
|
||||
|
||||
// retryOnBusy retries a function up to maxAttempts times if the error
|
||||
// contains "database is locked" or "database table is locked" (transient
|
||||
// SQLite contention). Returns the last error if all attempts fail.
|
||||
func retryOnBusy(maxAttempts int, fn func() error) error {
|
||||
var err error
|
||||
for i := range maxAttempts {
|
||||
err = fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "database is locked") && !strings.Contains(msg, "database table is locked") {
|
||||
return err
|
||||
}
|
||||
if i < maxAttempts-1 {
|
||||
time.Sleep(time.Duration(50*(i+1)) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// UISessionStore is the interface for UI session management
|
||||
// UISessionStore is defined in client.go (session management section)
|
||||
|
||||
@@ -211,9 +232,15 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if store, ok := s.uiSessionStore.(interface {
|
||||
CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error)
|
||||
}); ok {
|
||||
uiSessionID, err := store.CreateWithOAuth(did, handle, sessionData.HostURL, sessionID, 30*24*time.Hour)
|
||||
var uiSessionID string
|
||||
err := retryOnBusy(3, func() error {
|
||||
var createErr error
|
||||
uiSessionID, createErr = store.CreateWithOAuth(did, handle, sessionData.HostURL, sessionID, 30*24*time.Hour)
|
||||
return createErr
|
||||
})
|
||||
if err != nil {
|
||||
s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err))
|
||||
slog.Error("Failed to create UI session", "error", err, "did", did)
|
||||
s.renderError(w, "Something went wrong while logging you in. Please try again.")
|
||||
return
|
||||
}
|
||||
// Set UI session cookie and redirect (code below)
|
||||
@@ -229,9 +256,15 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
} else {
|
||||
// Fallback for stores that don't support OAuth sessionID
|
||||
uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour)
|
||||
var uiSessionID string
|
||||
err := retryOnBusy(3, func() error {
|
||||
var createErr error
|
||||
uiSessionID, createErr = s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour)
|
||||
return createErr
|
||||
})
|
||||
if err != nil {
|
||||
s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err))
|
||||
slog.Error("Failed to create UI session", "error", err, "did", did)
|
||||
s.renderError(w, "Something went wrong while logging you in. Please try again.")
|
||||
return
|
||||
}
|
||||
// Set UI session cookie
|
||||
|
||||
Reference in New Issue
Block a user