A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

show attestation details

evan.jarrett.net 8048921f de02e1f0

verified
+563 -24
+3
pkg/appview/db/migrations/0012_add_layer_annotations.yaml
··· 1 + description: Add annotations column to layers table for caching layer-level annotations (e.g. in-toto predicate types) 2 + query: | 3 + ALTER TABLE layers ADD COLUMN annotations TEXT;
+15 -5
pkg/appview/db/models.go
··· 29 29 30 30 // Layer represents a layer in a manifest 31 31 type Layer struct { 32 - ManifestID int64 33 - Digest string 34 - Size int64 35 - MediaType string 36 - LayerIndex int 32 + ManifestID int64 33 + Digest string 34 + Size int64 35 + MediaType string 36 + LayerIndex int 37 + Annotations map[string]string // JSON-encoded layer annotations (e.g. in-toto predicate type) 37 38 } 38 39 39 40 // ManifestReference represents a reference to a manifest in a manifest list/index ··· 149 150 Pending bool // Whether health check is still in progress 150 151 // Note: ArtifactType is available via embedded Manifest struct 151 152 } 153 + 154 + // AttestationDetail represents an attestation manifest and its layers 155 + type AttestationDetail struct { 156 + Digest string 157 + MediaType string // attestation manifest media type 158 + Size int64 159 + HoldEndpoint string // hold DID/URL where blobs are stored 160 + Layers []Layer 161 + }
+100 -6
pkg/appview/db/queries.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "encoding/json" 5 6 "fmt" 6 7 "strings" 7 8 "time" ··· 576 577 return id, nil 577 578 } 578 579 579 - // InsertLayer inserts a new layer record 580 + // InsertLayer inserts or updates a layer record. 581 + // Uses upsert so backfill re-processing populates new columns (e.g. annotations). 580 582 func InsertLayer(db DBTX, layer *Layer) error { 583 + var annotationsJSON *string 584 + if len(layer.Annotations) > 0 { 585 + b, err := json.Marshal(layer.Annotations) 586 + if err != nil { 587 + return fmt.Errorf("failed to marshal layer annotations: %w", err) 588 + } 589 + s := string(b) 590 + annotationsJSON = &s 591 + } 581 592 _, err := db.Exec(` 582 - INSERT INTO layers (manifest_id, digest, size, media_type, layer_index) 583 - VALUES (?, ?, ?, ?, ?) 584 - `, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex) 593 + INSERT INTO layers (manifest_id, digest, size, media_type, layer_index, annotations) 594 + VALUES (?, ?, ?, ?, ?, ?) 595 + ON CONFLICT(manifest_id, layer_index) DO UPDATE SET 596 + digest = excluded.digest, 597 + size = excluded.size, 598 + media_type = excluded.media_type, 599 + annotations = excluded.annotations 600 + `, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex, annotationsJSON) 585 601 return err 586 602 } 587 603 ··· 820 836 // GetLayersForManifest fetches all layers for a manifest 821 837 func GetLayersForManifest(db DBTX, manifestID int64) ([]Layer, error) { 822 838 rows, err := db.Query(` 823 - SELECT manifest_id, digest, size, media_type, layer_index 839 + SELECT manifest_id, digest, size, media_type, layer_index, annotations 824 840 FROM layers 825 841 WHERE manifest_id = ? 826 842 ORDER BY layer_index ··· 834 850 var layers []Layer 835 851 for rows.Next() { 836 852 var l Layer 837 - if err := rows.Scan(&l.ManifestID, &l.Digest, &l.Size, &l.MediaType, &l.LayerIndex); err != nil { 853 + var annotationsJSON sql.NullString 854 + if err := rows.Scan(&l.ManifestID, &l.Digest, &l.Size, &l.MediaType, &l.LayerIndex, &annotationsJSON); err != nil { 838 855 return nil, err 856 + } 857 + if annotationsJSON.Valid && annotationsJSON.String != "" { 858 + if err := json.Unmarshal([]byte(annotationsJSON.String), &l.Annotations); err != nil { 859 + return nil, fmt.Errorf("failed to unmarshal layer annotations: %w", err) 860 + } 839 861 } 840 862 layers = append(layers, l) 841 863 } ··· 1207 1229 } 1208 1230 1209 1231 return tags, nil 1232 + } 1233 + 1234 + // GetAttestationDetails returns attestation manifests and their layers for a manifest list. 1235 + // Joins manifest_references (is_attestation=true) → manifests → layers. 1236 + func GetAttestationDetails(db DBTX, did, repository, manifestListDigest string) ([]AttestationDetail, error) { 1237 + // Step 1: Get the manifest list ID and hold endpoint 1238 + var manifestListID int64 1239 + var parentHoldEndpoint string 1240 + err := db.QueryRow(` 1241 + SELECT id, hold_endpoint FROM manifests 1242 + WHERE did = ? AND repository = ? AND digest = ? 1243 + `, did, repository, manifestListDigest).Scan(&manifestListID, &parentHoldEndpoint) 1244 + if err != nil { 1245 + return nil, err 1246 + } 1247 + 1248 + // Step 2: Get attestation references and join to their manifest records 1249 + rows, err := db.Query(` 1250 + SELECT mr.digest, mr.media_type, mr.size, m.id 1251 + FROM manifest_references mr 1252 + LEFT JOIN manifests m ON m.digest = mr.digest AND m.did = ? AND m.repository = ? 1253 + WHERE mr.manifest_id = ? AND mr.is_attestation = 1 1254 + ORDER BY mr.reference_index 1255 + `, did, repository, manifestListID) 1256 + if err != nil { 1257 + return nil, err 1258 + } 1259 + defer rows.Close() 1260 + 1261 + type refRow struct { 1262 + digest string 1263 + mediaType string 1264 + size int64 1265 + manifestID *int64 // may be NULL if attestation manifest not indexed yet 1266 + } 1267 + var refs []refRow 1268 + for rows.Next() { 1269 + var r refRow 1270 + var mid sql.NullInt64 1271 + if err := rows.Scan(&r.digest, &r.mediaType, &r.size, &mid); err != nil { 1272 + return nil, err 1273 + } 1274 + if mid.Valid { 1275 + r.manifestID = &mid.Int64 1276 + } 1277 + refs = append(refs, r) 1278 + } 1279 + if err := rows.Err(); err != nil { 1280 + return nil, err 1281 + } 1282 + 1283 + // Step 3: For each attestation manifest, fetch its layers 1284 + // Use the parent manifest list's hold endpoint — attestation blobs are in the same hold 1285 + details := make([]AttestationDetail, 0, len(refs)) 1286 + for _, ref := range refs { 1287 + detail := AttestationDetail{ 1288 + Digest: ref.digest, 1289 + MediaType: ref.mediaType, 1290 + Size: ref.size, 1291 + HoldEndpoint: parentHoldEndpoint, 1292 + } 1293 + if ref.manifestID != nil { 1294 + layers, err := GetLayersForManifest(db, *ref.manifestID) 1295 + if err != nil { 1296 + return nil, err 1297 + } 1298 + detail.Layers = layers 1299 + } 1300 + details = append(details, detail) 1301 + } 1302 + 1303 + return details, nil 1210 1304 } 1211 1305 1212 1306 // BackfillState represents the backfill progress
+1
pkg/appview/db/schema.sql
··· 55 55 size INTEGER NOT NULL, 56 56 media_type TEXT NOT NULL, 57 57 layer_index INTEGER NOT NULL, 58 + annotations TEXT, 58 59 PRIMARY KEY(manifest_id, layer_index), 59 60 FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 60 61 );
+334
pkg/appview/handlers/attestation_details.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "net/http" 10 + "net/url" 11 + "strings" 12 + "time" 13 + 14 + "atcr.io/pkg/appview/db" 15 + "atcr.io/pkg/appview/middleware" 16 + "atcr.io/pkg/atproto" 17 + "atcr.io/pkg/auth" 18 + ) 19 + 20 + // AttestationDetailsHandler handles requests for attestation detail modal content. 21 + // Returns an HTML fragment (attestation-details partial) for insertion into the modal body. 22 + type AttestationDetailsHandler struct { 23 + BaseUIHandler 24 + } 25 + 26 + // attestationDetailsData is the template data for the attestation-details partial. 27 + type attestationDetailsData struct { 28 + Attestations []attestationInfo 29 + Error string 30 + LoginURL string // login URL with return_to for the current repo page 31 + } 32 + 33 + type attestationInfo struct { 34 + Digest string 35 + PredicateType string // human-friendly predicate type name 36 + RawJSON string // formatted JSON content of the attestation (empty for binary) 37 + MediaType string // layer media type (shown when content is not displayable) 38 + Size int64 // layer size in bytes 39 + FetchError string // non-empty if blob fetch failed 40 + NeedsLogin bool // true if blob fetch failed due to auth (403) 41 + } 42 + 43 + // predicateTypePrefixes maps in-toto predicate type URI prefixes to human-friendly names. 44 + // Prefix matching handles version drift (e.g. v1 → v1.1) without code changes. 45 + // Source: https://github.com/in-toto/attestation/tree/main/spec/predicates 46 + var predicateTypePrefixes = []struct { 47 + prefix string 48 + name string 49 + }{ 50 + {"https://slsa.dev/provenance/", "SLSA Provenance"}, 51 + {"https://slsa.dev/verification_summary/", "SLSA Verification Summary"}, 52 + {"https://spdx.dev/Document", "SPDX SBOM"}, 53 + {"https://cyclonedx.org/bom", "CycloneDX SBOM"}, 54 + {"https://in-toto.io/attestation/vulns", "Vulnerability Scan"}, 55 + {"https://in-toto.io/attestation/scai/", "SCAI Report"}, 56 + {"https://in-toto.io/attestation/link/", "in-toto Link"}, 57 + {"https://in-toto.io/attestation/runtime-trace/", "Runtime Trace"}, 58 + {"https://in-toto.io/attestation/release", "Release"}, 59 + {"https://in-toto.io/attestation/test-result/", "Test Result"}, 60 + {"https://in-toto.io/attestation/reference/", "Reference"}, 61 + } 62 + 63 + func friendlyPredicateType(predicateType string) string { 64 + for _, p := range predicateTypePrefixes { 65 + if strings.HasPrefix(predicateType, p.prefix) { 66 + return p.name 67 + } 68 + } 69 + if predicateType != "" { 70 + return predicateType 71 + } 72 + return "Unknown" 73 + } 74 + 75 + // inTotoStatement is the minimal in-toto statement structure for extracting the predicate type. 76 + type inTotoStatement struct { 77 + Type string `json:"_type"` 78 + PredicateType string `json:"predicateType"` 79 + Subject json.RawMessage `json:"subject"` 80 + Predicate json.RawMessage `json:"predicate"` 81 + } 82 + 83 + func (h *AttestationDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 84 + digest := r.URL.Query().Get("digest") 85 + did := r.URL.Query().Get("did") 86 + repo := r.URL.Query().Get("repo") 87 + 88 + if digest == "" || did == "" || repo == "" { 89 + h.renderDetails(w, attestationDetailsData{Error: "Missing required parameters"}) 90 + return 91 + } 92 + 93 + details, err := db.GetAttestationDetails(h.ReadOnlyDB, did, repo, digest) 94 + if err != nil { 95 + slog.Warn("Failed to fetch attestation details", "error", err, "digest", digest) 96 + h.renderDetails(w, attestationDetailsData{Error: "No attestation details found"}) 97 + return 98 + } 99 + 100 + if len(details) == 0 { 101 + h.renderDetails(w, attestationDetailsData{Error: "No attestations found for this manifest"}) 102 + return 103 + } 104 + 105 + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) 106 + defer cancel() 107 + 108 + // Look up repo owner for PDS endpoint and handle 109 + var pdsEndpoint string 110 + var loginURL string 111 + if repoOwner, err := db.GetUserByDID(h.ReadOnlyDB, did); err == nil && repoOwner != nil { 112 + pdsEndpoint = repoOwner.PDSEndpoint 113 + returnTo := fmt.Sprintf("/r/%s/%s", repoOwner.Handle, repo) 114 + loginURL = "/auth/oauth/login?return_to=" + url.QueryEscape(returnTo) 115 + } 116 + 117 + // If the viewer is logged in, get a service token so we can read from private holds 118 + var serviceToken string 119 + loggedIn := false 120 + if user := middleware.GetUser(r); user != nil && h.Refresher != nil { 121 + loggedIn = true 122 + holdDID := atproto.ResolveHoldDIDFromURL(details[0].HoldEndpoint) 123 + if holdDID == "" { 124 + holdDID = details[0].HoldEndpoint // might already be a DID 125 + } 126 + if token, err := auth.GetOrFetchServiceToken(ctx, h.Refresher, user.DID, holdDID, user.PDSEndpoint); err == nil { 127 + serviceToken = token 128 + } else { 129 + slog.Debug("Could not get service token for attestation fetch", "error", err) 130 + } 131 + } 132 + 133 + attestations := make([]attestationInfo, 0, len(details)) 134 + for _, d := range details { 135 + info := attestationInfo{ 136 + Digest: d.Digest, 137 + } 138 + 139 + // Resolve layer digest and annotations — prefer local DB, fallback to PDS 140 + layerDigest := "" 141 + if len(d.Layers) > 0 { 142 + layerDigest = d.Layers[0].Digest 143 + // Check local DB annotations for predicate type 144 + if pt, ok := d.Layers[0].Annotations["in-toto.io/predicate-type"]; ok { 145 + info.PredicateType = friendlyPredicateType(pt) 146 + } else if pdsEndpoint != "" { 147 + // Annotations not cached locally (pre-migration data) — fetch from PDS 148 + pdsLayers, err := fetchLayersFromPDS(ctx, pdsEndpoint, did, d.Digest) 149 + if err != nil { 150 + slog.Debug("Failed to fetch annotations from PDS for pre-migration layer", "error", err, "digest", d.Digest) 151 + } else if len(pdsLayers) > 0 { 152 + if pt, ok := pdsLayers[0].Annotations["in-toto.io/predicate-type"]; ok { 153 + info.PredicateType = friendlyPredicateType(pt) 154 + } 155 + } 156 + } 157 + } else if pdsEndpoint != "" { 158 + // Fallback: layers not in local DB yet — fetch from PDS 159 + pdsLayers, err := fetchLayersFromPDS(ctx, pdsEndpoint, did, d.Digest) 160 + if err != nil { 161 + slog.Warn("Failed to fetch attestation manifest from PDS", "error", err, "digest", d.Digest) 162 + } else if len(pdsLayers) > 0 { 163 + layerDigest = pdsLayers[0].Digest 164 + if pt, ok := pdsLayers[0].Annotations["in-toto.io/predicate-type"]; ok { 165 + info.PredicateType = friendlyPredicateType(pt) 166 + } 167 + } 168 + } 169 + 170 + if layerDigest != "" && d.HoldEndpoint != "" { 171 + content, err := fetchLayerBlob(ctx, d.HoldEndpoint, layerDigest, serviceToken) 172 + if err != nil { 173 + slog.Warn("Failed to fetch attestation blob", "error", err, "digest", layerDigest) 174 + if !loggedIn && strings.Contains(err.Error(), "403") { 175 + info.NeedsLogin = true 176 + } else { 177 + info.FetchError = "Could not fetch attestation content" 178 + } 179 + if info.PredicateType == "" { 180 + info.PredicateType = "Unknown" 181 + } 182 + } else { 183 + // Check if content is valid JSON before trying to display it 184 + var parsed json.RawMessage 185 + if json.Unmarshal(content, &parsed) == nil { 186 + // It's JSON — parse in-toto statement for predicate type (if not already set from annotations) 187 + if info.PredicateType == "" { 188 + var stmt inTotoStatement 189 + if err := json.Unmarshal(content, &stmt); err == nil { 190 + info.PredicateType = friendlyPredicateType(stmt.PredicateType) 191 + } else { 192 + info.PredicateType = "Unknown" 193 + } 194 + } 195 + // Pretty-print for display 196 + if pretty, err := json.MarshalIndent(parsed, "", " "); err == nil { 197 + info.RawJSON = string(pretty) 198 + } else { 199 + info.RawJSON = string(content) 200 + } 201 + } else { 202 + // Binary content — show media type and size instead 203 + if info.PredicateType == "" { 204 + info.PredicateType = "Binary Attachment" 205 + } 206 + info.Size = int64(len(content)) 207 + } 208 + } 209 + } else { 210 + if info.PredicateType == "" { 211 + info.PredicateType = "Unknown" 212 + } 213 + info.FetchError = "Attestation content not available" 214 + } 215 + 216 + attestations = append(attestations, info) 217 + } 218 + 219 + h.renderDetails(w, attestationDetailsData{Attestations: attestations, LoginURL: loginURL}) 220 + } 221 + 222 + // fetchLayersFromPDS fetches an attestation manifest record from the user's PDS 223 + // and returns its layers. Used as a fallback when layers aren't in the local DB. 224 + func fetchLayersFromPDS(ctx context.Context, pdsEndpoint, did, attestationDigest string) ([]atproto.BlobReference, error) { 225 + rkey := strings.TrimPrefix(attestationDigest, "sha256:") 226 + 227 + getRecordURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 228 + pdsEndpoint, 229 + url.QueryEscape(did), 230 + url.QueryEscape(atproto.ManifestCollection), 231 + url.QueryEscape(rkey), 232 + ) 233 + 234 + req, err := http.NewRequestWithContext(ctx, "GET", getRecordURL, nil) 235 + if err != nil { 236 + return nil, fmt.Errorf("build PDS request: %w", err) 237 + } 238 + 239 + resp, err := http.DefaultClient.Do(req) 240 + if err != nil { 241 + return nil, fmt.Errorf("PDS request: %w", err) 242 + } 243 + defer resp.Body.Close() 244 + 245 + if resp.StatusCode == http.StatusNotFound { 246 + return nil, nil // record doesn't exist 247 + } 248 + if resp.StatusCode != http.StatusOK { 249 + return nil, fmt.Errorf("PDS returned status %d", resp.StatusCode) 250 + } 251 + 252 + var result struct { 253 + Value atproto.ManifestRecord `json:"value"` 254 + } 255 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 256 + return nil, fmt.Errorf("parse PDS response: %w", err) 257 + } 258 + 259 + return result.Value.Layers, nil 260 + } 261 + 262 + // fetchLayerBlob fetches a small OCI layer blob from a hold service. 263 + // Two-hop flow: (1) get presigned URL from hold, (2) fetch blob from S3. 264 + // serviceToken is optional — pass "" for public holds. 265 + func fetchLayerBlob(ctx context.Context, holdEndpoint, layerDigest, serviceToken string) ([]byte, error) { 266 + holdURL := atproto.ResolveHoldURL(holdEndpoint) 267 + holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) 268 + if holdURL == "" || holdDID == "" { 269 + return nil, fmt.Errorf("could not resolve hold endpoint: %s", holdEndpoint) 270 + } 271 + 272 + // Step 1: Request presigned URL from hold 273 + getBlobURL := fmt.Sprintf("%s%s?did=%s&cid=%s", 274 + holdURL, 275 + atproto.SyncGetBlob, 276 + url.QueryEscape(holdDID), 277 + url.QueryEscape(layerDigest), 278 + ) 279 + 280 + req, err := http.NewRequestWithContext(ctx, "GET", getBlobURL, nil) 281 + if err != nil { 282 + return nil, fmt.Errorf("build request: %w", err) 283 + } 284 + if serviceToken != "" { 285 + req.Header.Set("Authorization", "Bearer "+serviceToken) 286 + } 287 + 288 + resp, err := http.DefaultClient.Do(req) 289 + if err != nil { 290 + return nil, fmt.Errorf("hold request: %w", err) 291 + } 292 + defer resp.Body.Close() 293 + 294 + if resp.StatusCode != http.StatusOK { 295 + return nil, fmt.Errorf("hold returned status %d", resp.StatusCode) 296 + } 297 + 298 + var presigned struct { 299 + URL string `json:"url"` 300 + } 301 + if err := json.NewDecoder(resp.Body).Decode(&presigned); err != nil { 302 + return nil, fmt.Errorf("parse presigned response: %w", err) 303 + } 304 + 305 + if presigned.URL == "" { 306 + return nil, fmt.Errorf("empty presigned URL") 307 + } 308 + 309 + // Step 2: Fetch blob from S3 310 + s3Req, err := http.NewRequestWithContext(ctx, "GET", presigned.URL, nil) 311 + if err != nil { 312 + return nil, fmt.Errorf("build S3 request: %w", err) 313 + } 314 + 315 + s3Resp, err := http.DefaultClient.Do(s3Req) 316 + if err != nil { 317 + return nil, fmt.Errorf("S3 request: %w", err) 318 + } 319 + defer s3Resp.Body.Close() 320 + 321 + if s3Resp.StatusCode != http.StatusOK { 322 + return nil, fmt.Errorf("S3 returned status %d", s3Resp.StatusCode) 323 + } 324 + 325 + // Limit read to 1MB to avoid loading huge blobs 326 + return io.ReadAll(io.LimitReader(s3Resp.Body, 1<<20)) 327 + } 328 + 329 + func (h *AttestationDetailsHandler) renderDetails(w http.ResponseWriter, data attestationDetailsData) { 330 + w.Header().Set("Content-Type", "text/html") 331 + if err := h.Templates.ExecuteTemplate(w, "attestation-details", data); err != nil { 332 + slog.Warn("Failed to render attestation details", "error", err) 333 + } 334 + }
+6 -5
pkg/appview/jetstream/processor.go
··· 364 364 // Insert layers (for image manifests) 365 365 for i, layer := range manifestRecord.Layers { 366 366 if err := db.InsertLayer(p.db, &db.Layer{ 367 - ManifestID: manifestID, 368 - Digest: layer.Digest, 369 - MediaType: layer.MediaType, 370 - Size: layer.Size, 371 - LayerIndex: i, 367 + ManifestID: manifestID, 368 + Digest: layer.Digest, 369 + MediaType: layer.MediaType, 370 + Size: layer.Size, 371 + LayerIndex: i, 372 + Annotations: layer.Annotations, 372 373 }); err != nil { 373 374 // Continue on error - layer might already exist 374 375 continue
+5
pkg/appview/routes/routes.go
··· 126 126 router.Get("/api/scan-result", (&uihandlers.ScanResultHandler{BaseUIHandler: base}).ServeHTTP) 127 127 router.Get("/api/vuln-details", (&uihandlers.VulnDetailsHandler{BaseUIHandler: base}).ServeHTTP) 128 128 129 + // Attestation details API endpoint (HTMX modal content) 130 + router.Get("/api/attestation-details", middleware.OptionalAuth(deps.SessionStore, deps.Database)( 131 + &uihandlers.AttestationDetailsHandler{BaseUIHandler: base}, 132 + ).ServeHTTP) 133 + 129 134 router.Get("/u/{handle}", middleware.OptionalAuth(deps.SessionStore, deps.Database)( 130 135 &uihandlers.UserPageHandler{BaseUIHandler: base}, 131 136 ).ServeHTTP)
+1 -1
pkg/appview/src/css/main.css
··· 228 228 COMMAND / CODE DISPLAY 229 229 ---------------------------------------- */ 230 230 .cmd { 231 - @apply flex items-center gap-2 relative w-full overflow-hidden; 231 + @apply flex items-center gap-2 relative w-full max-w-lg overflow-hidden; 232 232 @apply bg-base-200 border border-base-300 rounded-md; 233 233 @apply px-3 py-2; 234 234 }
+1 -1
pkg/appview/templates/components/docker-command.html
··· 8 8 <div class="cmd group"> 9 9 {{ icon "terminal" "size-4 shrink-0 text-base-content/60" }} 10 10 <code>{{ . }}</code> 11 - <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"> 11 + <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"> 12 12 {{ icon "copy" "size-4" }} 13 13 </button> 14 14 </div>
+28 -2
pkg/appview/templates/pages/repository.html
··· 123 123 <span class="badge badge-md badge-soft badge-accent">Multi-arch</span> 124 124 {{ end }} 125 125 {{ if .HasAttestations }} 126 - <span class="badge badge-md badge-soft badge-success">{{ icon "shield-check" "size-3" }} Attestations</span> 126 + <button class="badge badge-md badge-soft badge-success cursor-pointer hover:opacity-80" 127 + hx-get="/api/attestation-details?digest={{ .Tag.Digest | urlquery }}&did={{ $.Owner.DID | urlquery }}&repo={{ $.Repository.Name | urlquery }}" 128 + hx-target="#attestation-modal-body" 129 + hx-swap="innerHTML" 130 + onclick="document.getElementById('attestation-detail-modal').showModal()"> 131 + {{ icon "shield-check" "size-3" }} Attestations 132 + </button> 127 133 {{ end }} 128 134 </div> 129 135 <div class="flex items-center gap-2"> ··· 196 202 <span class="flex items-center gap-1 font-medium">{{ icon "box" "size-5" }} Image</span> 197 203 {{ end }} 198 204 {{ if .HasAttestations }} 199 - <span class="badge badge-md badge-soft badge-success">{{ icon "shield-check" "size-3" }} Attestations</span> 205 + <button class="badge badge-md badge-soft badge-success cursor-pointer hover:opacity-80" 206 + hx-get="/api/attestation-details?digest={{ .Manifest.Digest | urlquery }}&did={{ $.Owner.DID | urlquery }}&repo={{ $.Repository.Name | urlquery }}" 207 + hx-target="#attestation-modal-body" 208 + hx-swap="innerHTML" 209 + onclick="document.getElementById('attestation-detail-modal').showModal()"> 210 + {{ icon "shield-check" "size-3" }} Attestations 211 + </button> 200 212 {{ end }} 201 213 {{ if .Pending }} 202 214 <span class="badge badge-sm badge-info" ··· 295 307 <div class="modal-box max-w-4xl"> 296 308 <h3 class="text-lg font-bold">Vulnerability Scan Results</h3> 297 309 <div id="vuln-modal-body" class="py-4"> 310 + <span class="loading loading-spinner loading-md"></span> 311 + </div> 312 + <div class="modal-action"> 313 + <form method="dialog"><button class="btn">Close</button></form> 314 + </div> 315 + </div> 316 + <form method="dialog" class="modal-backdrop"><button>close</button></form> 317 + </dialog> 318 + 319 + <!-- Attestation Details Modal --> 320 + <dialog id="attestation-detail-modal" class="modal"> 321 + <div class="modal-box max-w-2xl"> 322 + <h3 class="text-lg font-bold">Attestation Details</h3> 323 + <div id="attestation-modal-body" class="py-4"> 298 324 <span class="loading loading-spinner loading-md"></span> 299 325 </div> 300 326 <div class="modal-action">
+32
pkg/appview/templates/partials/attestation-details.html
··· 1 + {{ define "attestation-details" }} 2 + {{ if .Error }} 3 + <p class="text-base-content/60">{{ .Error }}</p> 4 + {{ else }} 5 + <div class="space-y-4"> 6 + <p class="font-semibold text-sm">{{ len .Attestations }} attestation{{ if gt (len .Attestations) 1 }}s{{ end }} attached</p> 7 + 8 + {{ range .Attestations }} 9 + <div class="bg-base-200 rounded-lg p-4 space-y-3"> 10 + <div class="flex flex-wrap items-center justify-between gap-2"> 11 + <span class="badge badge-md badge-soft badge-success">{{ .PredicateType }}</span> 12 + <code class="font-mono text-xs text-base-content/60 truncate max-w-48" title="{{ .Digest }}">{{ .Digest }}</code> 13 + </div> 14 + {{ if .NeedsLogin }} 15 + <p class="text-sm text-base-content/50"><a href="{{ $.LoginURL }}" class="link link-primary">Log in</a> to view attestation content</p> 16 + {{ else if .FetchError }} 17 + <p class="text-sm text-base-content/50">{{ .FetchError }}</p> 18 + {{ else if .RawJSON }} 19 + <details> 20 + <summary class="cursor-pointer text-sm text-base-content/70 hover:text-base-content">View content</summary> 21 + <div class="mt-2 overflow-x-auto max-h-64"> 22 + <pre class="text-xs bg-base-300 rounded p-3 whitespace-pre-wrap break-all"><code>{{ .RawJSON }}</code></pre> 23 + </div> 24 + </details> 25 + {{ else if .Size }} 26 + <p class="text-sm text-base-content/50">Binary content ({{ .Size }} bytes) — cannot display inline</p> 27 + {{ end }} 28 + </div> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + {{ end }}
+37 -4
pkg/auth/oauth/server.go
··· 15 15 "github.com/bluesky-social/indigo/atproto/auth/oauth" 16 16 ) 17 17 18 + // retryOnBusy retries a function up to maxAttempts times if the error 19 + // contains "database is locked" or "database table is locked" (transient 20 + // SQLite contention). Returns the last error if all attempts fail. 21 + func retryOnBusy(maxAttempts int, fn func() error) error { 22 + var err error 23 + for i := range maxAttempts { 24 + err = fn() 25 + if err == nil { 26 + return nil 27 + } 28 + msg := err.Error() 29 + if !strings.Contains(msg, "database is locked") && !strings.Contains(msg, "database table is locked") { 30 + return err 31 + } 32 + if i < maxAttempts-1 { 33 + time.Sleep(time.Duration(50*(i+1)) * time.Millisecond) 34 + } 35 + } 36 + return err 37 + } 38 + 18 39 // UISessionStore is the interface for UI session management 19 40 // UISessionStore is defined in client.go (session management section) 20 41 ··· 211 232 if store, ok := s.uiSessionStore.(interface { 212 233 CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) 213 234 }); ok { 214 - uiSessionID, err := store.CreateWithOAuth(did, handle, sessionData.HostURL, sessionID, 30*24*time.Hour) 235 + var uiSessionID string 236 + err := retryOnBusy(3, func() error { 237 + var createErr error 238 + uiSessionID, createErr = store.CreateWithOAuth(did, handle, sessionData.HostURL, sessionID, 30*24*time.Hour) 239 + return createErr 240 + }) 215 241 if err != nil { 216 - s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) 242 + slog.Error("Failed to create UI session", "error", err, "did", did) 243 + s.renderError(w, "Something went wrong while logging you in. Please try again.") 217 244 return 218 245 } 219 246 // Set UI session cookie and redirect (code below) ··· 229 256 }) 230 257 } else { 231 258 // Fallback for stores that don't support OAuth sessionID 232 - uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour) 259 + var uiSessionID string 260 + err := retryOnBusy(3, func() error { 261 + var createErr error 262 + uiSessionID, createErr = s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour) 263 + return createErr 264 + }) 233 265 if err != nil { 234 - s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) 266 + slog.Error("Failed to create UI session", "error", err, "did", did) 267 + s.renderError(w, "Something went wrong while logging you in. Please try again.") 235 268 return 236 269 } 237 270 // Set UI session cookie