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