show attestation details

This commit is contained in:
Evan Jarrett
2026-02-13 19:40:05 -06:00
parent de02e1f046
commit 8048921f5e
12 changed files with 563 additions and 24 deletions

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
);

View 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)
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View 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 }}

View File

@@ -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