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

more did:plc fixes, more vulnerability scanner fixes

evan.jarrett.net 2df53775 10b35642

verified
+540 -315
+4
deploy/upcloud/provision.go
··· 328 328 if appviewCreated || holdCreated { 329 329 rootDir := projectRoot() 330 330 331 + if err := runGenerate(rootDir); err != nil { 332 + return fmt.Errorf("go generate: %w", err) 333 + } 334 + 331 335 fmt.Println("\nBuilding locally (GOOS=linux GOARCH=amd64)...") 332 336 if appviewCreated { 333 337 outputPath := filepath.Join(rootDir, "bin", "atcr-appview")
+16
deploy/upcloud/update.go
··· 114 114 return fmt.Errorf("unknown target: %s (use: all, appview, hold)", target) 115 115 } 116 116 117 + // Run go generate before building 118 + if err := runGenerate(rootDir); err != nil { 119 + return fmt.Errorf("go generate: %w", err) 120 + } 121 + 117 122 // Build all binaries locally before touching servers 118 123 fmt.Println("Building locally (GOOS=linux GOARCH=amd64)...") 119 124 for _, name := range toUpdate { ··· 297 302 BasePath: naming.BasePath(), 298 303 ScannerSecret: state.ScannerSecret, 299 304 } 305 + } 306 + 307 + // runGenerate runs go generate ./... in the given directory using host OS/arch 308 + // (no cross-compilation env vars — generate tools must run on the build machine). 309 + func runGenerate(dir string) error { 310 + fmt.Println("Running go generate ./...") 311 + cmd := exec.Command("go", "generate", "./...") 312 + cmd.Dir = dir 313 + cmd.Stdout = os.Stdout 314 + cmd.Stderr = os.Stderr 315 + return cmd.Run() 300 316 } 301 317 302 318 // buildLocal compiles a Go binary locally with cross-compilation flags for linux/amd64.
+7 -6
pkg/appview/handlers/attestation_details.go
··· 119 119 loggedIn := false 120 120 if user := middleware.GetUser(r); user != nil && h.Refresher != nil { 121 121 loggedIn = true 122 - holdDID := atproto.ResolveHoldDIDFromURL(details[0].HoldEndpoint) 123 - if holdDID == "" { 124 - holdDID = details[0].HoldEndpoint // might already be a DID 122 + holdDID, resolveErr := atproto.ResolveHoldDID(ctx, details[0].HoldEndpoint) 123 + if resolveErr != nil { 124 + slog.Debug("Could not resolve hold DID for service token", "holdEndpoint", details[0].HoldEndpoint, "error", resolveErr) 125 + holdDID = details[0].HoldEndpoint // fallback: use as-is 125 126 } 126 127 if token, err := auth.GetOrFetchServiceToken(ctx, h.Refresher, user.DID, holdDID, user.PDSEndpoint); err == nil { 127 128 serviceToken = token ··· 267 268 if err != nil { 268 269 return nil, fmt.Errorf("could not resolve hold endpoint %s: %w", holdEndpoint, err) 269 270 } 270 - holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) 271 - if holdDID == "" { 272 - return nil, fmt.Errorf("could not resolve hold DID from: %s", holdEndpoint) 271 + holdDID, err := atproto.ResolveHoldDID(ctx, holdEndpoint) 272 + if err != nil { 273 + return nil, fmt.Errorf("could not resolve hold DID from %s: %w", holdEndpoint, err) 273 274 } 274 275 275 276 // Step 1: Request presigned URL from hold
+30 -9
pkg/appview/handlers/scan_result.go
··· 46 46 return 47 47 } 48 48 49 - // Derive hold DID from endpoint URL 50 - holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) 51 - if holdDID == "" { 49 + // Resolve hold identity: holdEndpoint may be a DID or URL 50 + holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint) 51 + if err != nil { 52 + slog.Debug("Failed to resolve hold DID", "holdEndpoint", holdEndpoint, "error", err) 53 + h.renderBadge(w, vulnBadgeData{Error: true}) 54 + return 55 + } 56 + 57 + // Resolve to HTTP endpoint URL (handles DID, URL, or hostname) 58 + holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint) 59 + if err != nil { 60 + slog.Debug("Failed to resolve hold URL", "holdEndpoint", holdEndpoint, "error", err) 52 61 h.renderBadge(w, vulnBadgeData{Error: true}) 53 62 return 54 63 } ··· 61 70 defer cancel() 62 71 63 72 scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 64 - holdEndpoint, 73 + holdURL, 65 74 url.QueryEscape(holdDID), 66 75 url.QueryEscape(atproto.ScanCollection), 67 76 url.QueryEscape(rkey), ··· 116 125 ScannedAt: scanRecord.ScannedAt, 117 126 Found: true, 118 127 Digest: digest, 119 - HoldEndpoint: holdEndpoint, 128 + HoldEndpoint: holdDID, 120 129 }) 121 130 } 122 131 ··· 175 184 ScannedAt: scanRecord.ScannedAt, 176 185 Found: true, 177 186 Digest: fullDigest, 178 - HoldEndpoint: holdEndpoint, 187 + HoldEndpoint: holdDID, 179 188 } 180 189 } 181 190 ··· 199 208 digests = digests[:50] 200 209 } 201 210 202 - holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) 203 - if holdDID == "" { 211 + holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint) 212 + if err != nil { 204 213 // Can't resolve hold — render empty OOB spans 214 + slog.Debug("Failed to resolve hold DID for batch scan", "holdEndpoint", holdEndpoint, "error", err) 215 + w.Header().Set("Content-Type", "text/html") 216 + for _, d := range digests { 217 + fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d)) 218 + } 219 + return 220 + } 221 + 222 + // Resolve to HTTP endpoint URL (handles DID, URL, or hostname) 223 + holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint) 224 + if err != nil { 225 + slog.Debug("Failed to resolve hold URL for batch scan", "holdEndpoint", holdEndpoint, "error", err) 205 226 w.Header().Set("Content-Type", "text/html") 206 227 for _, d := range digests { 207 228 fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d)) ··· 229 250 ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) 230 251 defer cancel() 231 252 232 - results[idx].data = fetchScanRecord(ctx, holdEndpoint, holdDID, hex) 253 + results[idx].data = fetchScanRecord(ctx, holdURL, holdDID, hex) 233 254 }(i, hexDigest) 234 255 } 235 256 wg.Wait()
+34
pkg/appview/handlers/scan_result_test.go
··· 11 11 "atcr.io/pkg/appview/handlers" 12 12 ) 13 13 14 + // mockHoldDID is the DID returned by test hold servers for /.well-known/atproto-did 15 + const mockHoldDID = "did:web:hold.example.com" 16 + 17 + // handleMockDID serves /.well-known/atproto-did for test hold servers. 18 + // Returns true if the request was handled, false if it should be passed to the next handler. 19 + func handleMockDID(w http.ResponseWriter, r *http.Request) bool { 20 + if r.URL.Path == "/.well-known/atproto-did" { 21 + w.Write([]byte(mockHoldDID)) 22 + return true 23 + } 24 + return false 25 + } 26 + 14 27 // mockScanRecord returns a getRecord JSON envelope wrapping a scan record 15 28 func mockScanRecord(critical, high, medium, low, total int64) string { 16 29 record := map[string]any{ ··· 51 64 func TestScanResult_WithVulnerabilities(t *testing.T) { 52 65 // Mock hold that returns a scan record with vulnerabilities 53 66 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 67 + if handleMockDID(w, r) { 68 + return 69 + } 54 70 w.Header().Set("Content-Type", "application/json") 55 71 w.Write([]byte(mockScanRecord(2, 5, 10, 3, 20))) 56 72 })) ··· 96 112 func TestScanResult_Clean(t *testing.T) { 97 113 // Mock hold that returns a scan record with zero vulnerabilities 98 114 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 115 + if handleMockDID(w, r) { 116 + return 117 + } 99 118 w.Header().Set("Content-Type", "application/json") 100 119 w.Write([]byte(mockScanRecord(0, 0, 0, 0, 0))) 101 120 })) ··· 124 143 func TestScanResult_NotFound(t *testing.T) { 125 144 // Mock hold that returns 404 (no scan record — scanning disabled or not yet scanned) 126 145 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 146 + if handleMockDID(w, r) { 147 + return 148 + } 127 149 http.Error(w, "record not found", http.StatusNotFound) 128 150 })) 129 151 defer hold.Close() ··· 145 167 func TestScanResult_HoldError(t *testing.T) { 146 168 // Mock hold that returns 500 147 169 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 170 + if handleMockDID(w, r) { 171 + return 172 + } 148 173 http.Error(w, "internal error", http.StatusInternalServerError) 149 174 })) 150 175 defer hold.Close() ··· 226 251 func TestScanResult_OnlyCriticalShown(t *testing.T) { 227 252 // Only critical vulns, no high/medium/low 228 253 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 254 + if handleMockDID(w, r) { 255 + return 256 + } 229 257 w.Header().Set("Content-Type", "application/json") 230 258 w.Write([]byte(mockScanRecord(3, 0, 0, 0, 3))) 231 259 })) ··· 272 300 func TestBatchScanResult_MultipleDigests(t *testing.T) { 273 301 // Mock hold that returns different results based on rkey 274 302 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 303 + if handleMockDID(w, r) { 304 + return 305 + } 275 306 rkey := r.URL.Query().Get("rkey") 276 307 w.Header().Set("Content-Type", "application/json") 277 308 switch rkey { ··· 379 410 380 411 func TestBatchScanResult_SingleDigest(t *testing.T) { 381 412 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 413 + if handleMockDID(w, r) { 414 + return 415 + } 382 416 w.Header().Set("Content-Type", "application/json") 383 417 w.Write([]byte(mockScanRecord(1, 0, 0, 0, 1))) 384 418 }))
+32 -19
pkg/appview/handlers/vuln_details.go
··· 21 21 } 22 22 23 23 // grypeReport is the minimal Grype JSON structure we need. 24 + // Grype v0.107+ uses PascalCase JSON keys. 24 25 type grypeReport struct { 25 26 Matches []grypeMatch `json:"matches"` 26 27 } 27 28 28 29 type grypeMatch struct { 29 - Vulnerability grypeVuln `json:"vulnerability"` 30 - Artifact grypeArtifact `json:"artifact"` 30 + Vulnerability grypeVuln `json:"Vulnerability"` 31 + Package grypePackage `json:"Package"` 31 32 } 32 33 33 34 type grypeVuln struct { 34 - ID string `json:"id"` 35 - Severity string `json:"severity"` 36 - Fix grypeFix `json:"fix"` 35 + ID string `json:"ID"` 36 + Metadata grypeMetadata `json:"Metadata"` 37 + Fix grypeFix `json:"Fix"` 38 + } 39 + 40 + type grypeMetadata struct { 41 + Severity string `json:"Severity"` 37 42 } 38 43 39 44 type grypeFix struct { 40 - Versions []string `json:"versions"` 41 - State string `json:"state"` 45 + Versions []string `json:"Versions"` 46 + State string `json:"State"` 42 47 } 43 48 44 - type grypeArtifact struct { 45 - Name string `json:"name"` 46 - Version string `json:"version"` 47 - Type string `json:"type"` 49 + type grypePackage struct { 50 + Name string `json:"Name"` 51 + Version string `json:"Version"` 52 + Type string `json:"Type"` 48 53 } 49 54 50 55 // vulnDetailsData is the template data for the vuln-details partial. ··· 92 97 return 93 98 } 94 99 95 - holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) 96 - if holdDID == "" { 100 + holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint) 101 + if err != nil { 102 + slog.Debug("Failed to resolve hold DID", "holdEndpoint", holdEndpoint, "error", err) 97 103 h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold identity"}) 104 + return 105 + } 106 + 107 + // Resolve to HTTP endpoint URL (handles DID, URL, or hostname) 108 + holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint) 109 + if err != nil { 110 + h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold endpoint"}) 98 111 return 99 112 } 100 113 ··· 105 118 106 119 // Step 1: Fetch the scan record to get the VulnReportBlob CID 107 120 scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 108 - holdEndpoint, 121 + holdURL, 109 122 url.QueryEscape(holdDID), 110 123 url.QueryEscape(atproto.ScanCollection), 111 124 url.QueryEscape(rkey), ··· 163 176 164 177 blobCID := scanRecord.VulnReportBlob.Ref.String() 165 178 blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 166 - holdEndpoint, 179 + holdURL, 167 180 url.QueryEscape(holdDID), 168 181 url.QueryEscape(blobCID), 169 182 ) ··· 211 224 matches = append(matches, vulnMatch{ 212 225 CVEID: m.Vulnerability.ID, 213 226 CVEURL: cveURL, 214 - Severity: m.Vulnerability.Severity, 215 - Package: m.Artifact.Name, 216 - Version: m.Artifact.Version, 227 + Severity: m.Vulnerability.Metadata.Severity, 228 + Package: m.Package.Name, 229 + Version: m.Package.Version, 217 230 FixedIn: fixedIn, 218 - Type: m.Artifact.Type, 231 + Type: m.Package.Type, 219 232 }) 220 233 } 221 234
+15 -1
pkg/appview/handlers/vuln_details_test.go
··· 159 159 func TestVulnDetails_FullReport(t *testing.T) { 160 160 grypeJSON := mockGrypeReport() 161 161 162 - // Mock hold that serves both getRecord and getBlob 162 + // Mock hold that serves DID resolution, getRecord, and getBlob 163 163 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 164 164 path := r.URL.Path 165 + if path == "/.well-known/atproto-did" { 166 + w.Write([]byte("did:web:hold.example.com")) 167 + return 168 + } 165 169 if strings.Contains(path, "getRecord") { 166 170 w.Header().Set("Content-Type", "application/json") 167 171 w.Write([]byte(mockScanRecordWithBlob(1, 1, 0, 1, 3))) ··· 231 235 func TestVulnDetails_NoVulnReportBlob(t *testing.T) { 232 236 // Mock hold returns scan record WITHOUT VulnReportBlob 233 237 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 238 + if handleMockDID(w, r) { 239 + return 240 + } 234 241 w.Header().Set("Content-Type", "application/json") 235 242 w.Write([]byte(mockScanRecordWithoutBlob(2, 5, 10, 3, 20))) 236 243 })) ··· 263 270 func TestVulnDetails_NotFound(t *testing.T) { 264 271 // Mock hold returns 404 265 272 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 273 + if handleMockDID(w, r) { 274 + return 275 + } 266 276 http.Error(w, "not found", http.StatusNotFound) 267 277 })) 268 278 defer hold.Close() ··· 286 296 287 297 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 288 298 path := r.URL.Path 299 + if path == "/.well-known/atproto-did" { 300 + w.Write([]byte("did:web:hold.example.com")) 301 + return 302 + } 289 303 if strings.Contains(path, "getRecord") { 290 304 w.Header().Set("Content-Type", "application/json") 291 305 w.Write([]byte(mockScanRecordWithBlob(1, 1, 0, 1, 3)))
-54
pkg/appview/holdhealth/checker_test.go
··· 6 6 "net/http/httptest" 7 7 "testing" 8 8 "time" 9 - 10 - "atcr.io/pkg/atproto" 11 9 ) 12 10 13 11 func TestNewChecker(t *testing.T) { ··· 270 268 } 271 269 } 272 270 273 - func TestNormalizeHoldEndpoint(t *testing.T) { 274 - tests := []struct { 275 - name string 276 - input string 277 - expected string 278 - }{ 279 - { 280 - name: "HTTP URL", 281 - input: "http://hold01.atcr.io", 282 - expected: "did:web:hold01.atcr.io", 283 - }, 284 - { 285 - name: "HTTPS URL", 286 - input: "https://hold01.atcr.io", 287 - expected: "did:web:hold01.atcr.io", 288 - }, 289 - { 290 - name: "HTTP URL with port", 291 - input: "http://172.28.0.3:8080", 292 - expected: "did:web:172.28.0.3:8080", 293 - }, 294 - { 295 - name: "HTTP URL with trailing slash", 296 - input: "http://hold01.atcr.io/", 297 - expected: "did:web:hold01.atcr.io", 298 - }, 299 - { 300 - name: "HTTP URL with path", 301 - input: "http://hold01.atcr.io/some/path", 302 - expected: "did:web:hold01.atcr.io", 303 - }, 304 - { 305 - name: "Already a DID", 306 - input: "did:web:hold01.atcr.io", 307 - expected: "did:web:hold01.atcr.io", 308 - }, 309 - { 310 - name: "DID with port", 311 - input: "did:web:172.28.0.3:8080", 312 - expected: "did:web:172.28.0.3:8080", 313 - }, 314 - } 315 - 316 - for _, tt := range tests { 317 - t.Run(tt.name, func(t *testing.T) { 318 - result := atproto.ResolveHoldDIDFromURL(tt.input) 319 - if result != tt.expected { 320 - t.Errorf("normalizeHoldEndpoint(%q) = %q, want %q", tt.input, result, tt.expected) 321 - } 322 - }) 323 - } 324 - }
+5 -1
pkg/appview/holdhealth/worker.go
··· 127 127 128 128 for _, endpoint := range endpoints { 129 129 // Normalize to canonical DID format 130 - normalizedDID := atproto.ResolveHoldDIDFromURL(endpoint) 130 + normalizedDID, err := atproto.ResolveHoldDID(ctx, endpoint) 131 + if err != nil { 132 + slog.Debug("Failed to resolve hold DID during health check", "endpoint", endpoint, "error", err) 133 + continue 134 + } 131 135 132 136 // Skip if we've already seen this normalized DID 133 137 if seen[normalizedDID] {
+9 -5
pkg/appview/jetstream/processor.go
··· 252 252 // Old manifests use holdEndpoint field (URL format) - convert to DID 253 253 holdDID := manifestRecord.HoldDID 254 254 if holdDID == "" && manifestRecord.HoldEndpoint != "" { 255 - // Legacy manifest - convert URL to DID 256 - holdDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint) 255 + // Legacy manifest - resolve URL to DID via /.well-known/atproto-did 256 + if resolved, err := atproto.ResolveHoldDID(ctx, manifestRecord.HoldEndpoint); err != nil { 257 + slog.Warn("Failed to resolve hold DID from legacy manifest endpoint", "holdEndpoint", manifestRecord.HoldEndpoint, "error", err) 258 + } else { 259 + holdDID = resolved 260 + } 257 261 } 258 262 259 263 // Detect artifact type from config media type ··· 445 449 } 446 450 447 451 // Convert hold URL/DID to canonical DID 448 - holdDID := atproto.ResolveHoldDIDFromURL(profileRecord.DefaultHold) 449 - if holdDID == "" { 450 - slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold) 452 + holdDID, err := atproto.ResolveHoldDID(ctx, profileRecord.DefaultHold) 453 + if err != nil { 454 + slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold, "error", err) 451 455 return nil 452 456 } 453 457
+9 -6
pkg/appview/server.go
··· 352 352 if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") { 353 353 slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold) 354 354 355 - holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold) 356 - 357 - profile.DefaultHold = holdDID 358 - if err := storage.UpdateProfile(ctx, client, profile); err != nil { 359 - slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err) 355 + if resolvedDID, resolveErr := atproto.ResolveHoldDID(ctx, profile.DefaultHold); resolveErr != nil { 356 + slog.Warn("Failed to resolve hold DID from URL", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold, "error", resolveErr) 360 357 } else { 361 - slog.Debug("Updated profile with hold DID", "component", "appview/callback", "hold_did", holdDID) 358 + holdDID = resolvedDID 359 + profile.DefaultHold = holdDID 360 + if err := storage.UpdateProfile(ctx, client, profile); err != nil { 361 + slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err) 362 + } else { 363 + slog.Debug("Updated profile with hold DID", "component", "appview/callback", "hold_did", holdDID) 364 + } 362 365 } 363 366 } else { 364 367 holdDID = profile.DefaultHold
+30
pkg/appview/src/css/main.css
··· 368 368 .menu li > form > label { 369 369 @apply block w-full; 370 370 } 371 + 372 + /* ---------------------------------------- 373 + OFFLINE MANIFEST FILTERING 374 + Hide offline manifests by default; 375 + show when "Show offline images" is checked 376 + ---------------------------------------- */ 377 + .manifests-list > [data-reachable="false"] { 378 + display: none; 379 + } 380 + 381 + .manifests-list.show-offline > [data-reachable="false"] { 382 + display: block; 383 + } 384 + 385 + /* ---------------------------------------- 386 + VULNERABILITY SEVERITY BOX STRIP 387 + Docker Hub-style connected severity boxes 388 + ---------------------------------------- */ 389 + .vuln-strip { 390 + @apply inline-flex items-stretch text-xs font-semibold leading-none; 391 + } 392 + .vuln-strip > span { 393 + @apply px-2 py-1 min-w-[1.75rem] text-center cursor-pointer; 394 + } 395 + .vuln-strip > span:first-child { @apply rounded-l; } 396 + .vuln-strip > span:last-child { @apply rounded-r; } 397 + .vuln-box-critical { background-color: oklch(45% 0.16 20); color: oklch(97% 0.01 20); } 398 + .vuln-box-high { background-color: oklch(58% 0.18 35); color: oklch(97% 0.01 35); } 399 + .vuln-box-medium { background-color: oklch(72% 0.15 70); color: oklch(25% 0.05 70); } 400 + .vuln-box-low { background-color: oklch(80% 0.1 85); color: oklch(25% 0.05 85); } 371 401 }
+3 -3
pkg/appview/storage/crew.go
··· 23 23 } 24 24 25 25 // Normalize URL to DID if needed 26 - holdDID := atproto.ResolveHoldDIDFromURL(defaultHoldDID) 27 - if holdDID == "" { 28 - slog.Warn("failed to resolve hold DID", "defaultHold", defaultHoldDID) 26 + holdDID, err := atproto.ResolveHoldDID(ctx, defaultHoldDID) 27 + if err != nil { 28 + slog.Warn("failed to resolve hold DID", "defaultHold", defaultHoldDID, "error", err) 29 29 return 30 30 } 31 31
+4 -2
pkg/appview/storage/drain.go
··· 92 92 needsRewrite := false 93 93 if manifest.HoldDID == oldHold { 94 94 needsRewrite = true 95 - } else if manifest.HoldEndpoint != "" && atproto.ResolveHoldDIDFromURL(manifest.HoldEndpoint) == oldHold { 96 - needsRewrite = true 95 + } else if manifest.HoldEndpoint != "" { 96 + if resolvedDID, resolveErr := atproto.ResolveHoldDID(ctx, manifest.HoldEndpoint); resolveErr == nil && resolvedDID == oldHold { 97 + needsRewrite = true 98 + } 97 99 } 98 100 99 101 if !needsRewrite {
+41 -28
pkg/appview/storage/profile.go
··· 36 36 // This ensures we store DIDs consistently in new profiles 37 37 normalizedDID := "" 38 38 if defaultHoldDID != "" { 39 - normalizedDID = atproto.ResolveHoldDIDFromURL(defaultHoldDID) 39 + resolved, err := atproto.ResolveHoldDID(ctx, defaultHoldDID) 40 + if err != nil { 41 + slog.Warn("Failed to resolve hold DID for new profile", "component", "profile", "defaultHold", defaultHoldDID, "error", err) 42 + } else { 43 + normalizedDID = resolved 44 + } 40 45 } 41 46 42 47 // Profile doesn't exist - create it ··· 73 78 // Migrate old URL-based defaultHold to DID format 74 79 // This ensures backward compatibility with profiles created before DID migration 75 80 if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) { 76 - // Convert URL to DID transparently 77 - migratedDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold) 78 - profile.DefaultHold = migratedDID 81 + // Convert URL to DID by querying /.well-known/atproto-did 82 + migratedDID, resolveErr := atproto.ResolveHoldDID(ctx, profile.DefaultHold) 83 + if resolveErr != nil { 84 + slog.Warn("Failed to resolve hold DID during profile migration", "component", "profile", "defaultHold", profile.DefaultHold, "error", resolveErr) 85 + } else { 86 + profile.DefaultHold = migratedDID 79 87 80 - // Persist the migration to PDS in a background goroutine 81 - // Use a lock to ensure only one goroutine migrates this DID 82 - did := client.DID() 83 - if _, loaded := migrationLocks.LoadOrStore(did, true); !loaded { 84 - // We got the lock - launch goroutine to persist the migration 85 - go func() { 86 - // Clean up lock when done (after a short delay to batch requests) 87 - defer func() { 88 - time.Sleep(1 * time.Second) 89 - migrationLocks.Delete(did) 90 - }() 88 + // Persist the migration to PDS in a background goroutine 89 + // Use a lock to ensure only one goroutine migrates this DID 90 + did := client.DID() 91 + if _, loaded := migrationLocks.LoadOrStore(did, true); !loaded { 92 + // We got the lock - launch goroutine to persist the migration 93 + go func() { 94 + // Clean up lock when done (after a short delay to batch requests) 95 + defer func() { 96 + time.Sleep(1 * time.Second) 97 + migrationLocks.Delete(did) 98 + }() 91 99 92 - // Create a new context with timeout for the background operation 93 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 94 - defer cancel() 100 + // Create a new context with timeout for the background operation 101 + bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 102 + defer cancel() 95 103 96 - // Update the profile on the PDS 97 - profile.UpdatedAt = time.Now() 98 - if err := UpdateProfile(ctx, client, &profile); err != nil { 99 - slog.Warn("Failed to persist URL-to-DID migration", "component", "profile", "did", did, "error", err) 100 - } else { 101 - slog.Debug("Persisted defaultHold migration to DID", "component", "profile", "migrated_did", migratedDID, "did", did) 102 - } 103 - }() 104 + // Update the profile on the PDS 105 + profile.UpdatedAt = time.Now() 106 + if err := UpdateProfile(bgCtx, client, &profile); err != nil { 107 + slog.Warn("Failed to persist URL-to-DID migration", "component", "profile", "did", did, "error", err) 108 + } else { 109 + slog.Debug("Persisted defaultHold migration to DID", "component", "profile", "migrated_did", migratedDID, "did", did) 110 + } 111 + }() 112 + } 104 113 } 105 114 } 106 115 ··· 113 122 // Normalize defaultHold to DID if it's a URL 114 123 // This ensures we always store DIDs, even if user provides a URL 115 124 if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) { 116 - profile.DefaultHold = atproto.ResolveHoldDIDFromURL(profile.DefaultHold) 117 - slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", profile.DefaultHold) 125 + if resolved, err := atproto.ResolveHoldDID(ctx, profile.DefaultHold); err != nil { 126 + slog.Warn("Failed to resolve hold DID during profile update", "component", "profile", "defaultHold", profile.DefaultHold, "error", err) 127 + } else { 128 + profile.DefaultHold = resolved 129 + slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", profile.DefaultHold) 130 + } 118 131 } 119 132 120 133 _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, profile)
+206 -39
pkg/appview/storage/profile_test.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "fmt" 6 7 "net/http" 7 8 "net/http/httptest" 8 9 "strings" ··· 23 24 { 24 25 name: "with DID", 25 26 defaultHoldDID: "did:web:hold01.atcr.io", 26 - wantNormalized: "did:web:hold01.atcr.io", 27 - }, 28 - { 29 - name: "with URL - should normalize to DID", 30 - defaultHoldDID: "https://hold01.atcr.io", 31 27 wantNormalized: "did:web:hold01.atcr.io", 32 28 }, 33 29 { ··· 104 100 } 105 101 }) 106 102 } 103 + 104 + // URL normalization test uses a local test server for /.well-known/atproto-did 105 + t.Run("with URL - should normalize to DID", func(t *testing.T) { 106 + var createdProfile *atproto.SailorProfileRecord 107 + 108 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 109 + // Handle hold DID resolution 110 + if r.URL.Path == "/.well-known/atproto-did" { 111 + w.Write([]byte("did:web:hold01.atcr.io")) 112 + return 113 + } 114 + 115 + // GetRecord: profile doesn't exist 116 + if r.Method == "GET" { 117 + w.WriteHeader(http.StatusNotFound) 118 + return 119 + } 120 + 121 + // PutRecord: create profile 122 + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { 123 + var body map[string]any 124 + json.NewDecoder(r.Body).Decode(&body) 125 + 126 + recordData := body["record"].(map[string]any) 127 + defaultHold := recordData["defaultHold"] 128 + defaultHoldStr := "" 129 + if defaultHold != nil { 130 + defaultHoldStr = defaultHold.(string) 131 + } 132 + if defaultHoldStr != "did:web:hold01.atcr.io" { 133 + t.Errorf("defaultHold = %v, want did:web:hold01.atcr.io", defaultHoldStr) 134 + } 135 + 136 + profileBytes, _ := json.Marshal(recordData) 137 + json.Unmarshal(profileBytes, &createdProfile) 138 + 139 + w.WriteHeader(http.StatusOK) 140 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) 141 + return 142 + } 143 + 144 + w.WriteHeader(http.StatusBadRequest) 145 + })) 146 + defer server.Close() 147 + 148 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 149 + err := EnsureProfile(context.Background(), client, server.URL) 150 + if err != nil { 151 + t.Fatalf("EnsureProfile() error = %v", err) 152 + } 153 + 154 + if createdProfile == nil { 155 + t.Fatal("Profile was not created") 156 + } 157 + 158 + if createdProfile.DefaultHold != "did:web:hold01.atcr.io" { 159 + t.Errorf("DefaultHold = %v, want did:web:hold01.atcr.io", createdProfile.DefaultHold) 160 + } 161 + }) 107 162 } 108 163 109 164 // TestEnsureProfile_Exists tests that EnsureProfile doesn't recreate existing profiles ··· 176 231 wantNil: false, 177 232 wantErr: false, 178 233 expectMigration: false, 179 - expectedHoldDID: "did:web:hold01.atcr.io", 180 - }, 181 - { 182 - name: "profile with URL (migration needed)", 183 - serverResponse: `{ 184 - "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", 185 - "value": { 186 - "$type": "io.atcr.sailor.profile", 187 - "defaultHold": "https://hold01.atcr.io", 188 - "createdAt": "2025-01-01T00:00:00Z", 189 - "updatedAt": "2025-01-01T00:00:00Z" 190 - } 191 - }`, 192 - serverStatus: http.StatusOK, 193 - wantNil: false, 194 - wantErr: false, 195 - expectMigration: true, 196 - originalHoldURL: "https://hold01.atcr.io", 197 234 expectedHoldDID: "did:web:hold01.atcr.io", 198 235 }, 199 236 { ··· 293 330 } 294 331 }) 295 332 } 333 + 334 + // URL migration test uses a local test server for /.well-known/atproto-did 335 + t.Run("profile with URL (migration needed)", func(t *testing.T) { 336 + migrationLocks = sync.Map{} 337 + 338 + var mu sync.Mutex 339 + putRecordCalled := false 340 + var migrationRequest map[string]any 341 + 342 + var server *httptest.Server 343 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 344 + // Handle hold DID resolution 345 + if r.URL.Path == "/.well-known/atproto-did" { 346 + w.Write([]byte("did:web:hold01.atcr.io")) 347 + return 348 + } 349 + 350 + // GetRecord - return profile with URL pointing to this server 351 + if r.Method == "GET" { 352 + response := fmt.Sprintf(`{ 353 + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", 354 + "value": { 355 + "$type": "io.atcr.sailor.profile", 356 + "defaultHold": %q, 357 + "createdAt": "2025-01-01T00:00:00Z", 358 + "updatedAt": "2025-01-01T00:00:00Z" 359 + } 360 + }`, server.URL) 361 + w.WriteHeader(http.StatusOK) 362 + w.Write([]byte(response)) 363 + return 364 + } 365 + 366 + // PutRecord (migration) 367 + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { 368 + mu.Lock() 369 + putRecordCalled = true 370 + json.NewDecoder(r.Body).Decode(&migrationRequest) 371 + mu.Unlock() 372 + w.WriteHeader(http.StatusOK) 373 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) 374 + return 375 + } 376 + })) 377 + defer server.Close() 378 + 379 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 380 + profile, err := GetProfile(context.Background(), client) 381 + 382 + if err != nil { 383 + t.Fatalf("GetProfile() error = %v", err) 384 + } 385 + 386 + if profile == nil { 387 + t.Fatal("GetProfile() returned nil, want profile") 388 + } 389 + 390 + if profile.DefaultHold != "did:web:hold01.atcr.io" { 391 + t.Errorf("DefaultHold = %v, want did:web:hold01.atcr.io", profile.DefaultHold) 392 + } 393 + 394 + // Give migration goroutine time to execute 395 + time.Sleep(50 * time.Millisecond) 396 + 397 + mu.Lock() 398 + called := putRecordCalled 399 + request := migrationRequest 400 + mu.Unlock() 401 + 402 + if !called { 403 + t.Error("Expected migration PutRecord to be called") 404 + } 405 + 406 + if request != nil { 407 + recordData := request["record"].(map[string]any) 408 + migratedHold := recordData["defaultHold"] 409 + if migratedHold != "did:web:hold01.atcr.io" { 410 + t.Errorf("Migrated defaultHold = %v, want did:web:hold01.atcr.io", migratedHold) 411 + } 412 + } 413 + }) 296 414 } 297 415 298 416 // TestGetProfile_MigrationLocking tests that concurrent migrations don't happen ··· 303 421 putRecordCount := 0 304 422 var mu sync.Mutex 305 423 306 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 307 - // GetRecord - return profile with URL 424 + var server *httptest.Server 425 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 426 + // Handle hold DID resolution 427 + if r.URL.Path == "/.well-known/atproto-did" { 428 + w.Write([]byte("did:web:hold01.atcr.io")) 429 + return 430 + } 431 + 432 + // GetRecord - return profile with URL pointing to this server 308 433 if r.Method == "GET" { 309 - response := `{ 434 + response := fmt.Sprintf(`{ 310 435 "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", 311 436 "value": { 312 437 "$type": "io.atcr.sailor.profile", 313 - "defaultHold": "https://hold01.atcr.io", 438 + "defaultHold": %q, 314 439 "createdAt": "2025-01-01T00:00:00Z", 315 440 "updatedAt": "2025-01-01T00:00:00Z" 316 441 } 317 - }` 442 + }`, server.URL) 318 443 w.WriteHeader(http.StatusOK) 319 444 w.Write([]byte(response)) 320 445 return ··· 384 509 wantErr: false, 385 510 }, 386 511 { 387 - name: "update with URL - should normalize", 388 - profile: &atproto.SailorProfileRecord{ 389 - Type: atproto.SailorProfileCollection, 390 - DefaultHold: "https://hold02.atcr.io", 391 - CreatedAt: time.Now(), 392 - UpdatedAt: time.Now(), 393 - }, 394 - wantNormalized: "did:web:hold02.atcr.io", 395 - wantErr: false, 396 - }, 397 - { 398 512 name: "clear default hold", 399 513 profile: &atproto.SailorProfileRecord{ 400 514 Type: atproto.SailorProfileCollection, ··· 458 572 } 459 573 }) 460 574 } 575 + 576 + // URL normalization test uses a local test server for /.well-known/atproto-did 577 + t.Run("update with URL - should normalize", func(t *testing.T) { 578 + var sentProfile map[string]any 579 + 580 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 581 + // Handle hold DID resolution 582 + if r.URL.Path == "/.well-known/atproto-did" { 583 + w.Write([]byte("did:web:hold02.atcr.io")) 584 + return 585 + } 586 + 587 + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { 588 + var body map[string]any 589 + json.NewDecoder(r.Body).Decode(&body) 590 + sentProfile = body 591 + 592 + if body["rkey"] != ProfileRKey { 593 + t.Errorf("rkey = %v, want %v", body["rkey"], ProfileRKey) 594 + } 595 + 596 + w.WriteHeader(http.StatusOK) 597 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) 598 + return 599 + } 600 + w.WriteHeader(http.StatusBadRequest) 601 + })) 602 + defer server.Close() 603 + 604 + profile := &atproto.SailorProfileRecord{ 605 + Type: atproto.SailorProfileCollection, 606 + DefaultHold: server.URL, // URL pointing to test server with /.well-known/atproto-did 607 + CreatedAt: time.Now(), 608 + UpdatedAt: time.Now(), 609 + } 610 + 611 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 612 + err := UpdateProfile(context.Background(), client, profile) 613 + if err != nil { 614 + t.Errorf("UpdateProfile() error = %v", err) 615 + return 616 + } 617 + 618 + recordData := sentProfile["record"].(map[string]any) 619 + defaultHold := recordData["defaultHold"].(string) 620 + if defaultHold != "did:web:hold02.atcr.io" { 621 + t.Errorf("defaultHold = %v, want did:web:hold02.atcr.io", defaultHold) 622 + } 623 + 624 + if profile.DefaultHold != "did:web:hold02.atcr.io" { 625 + t.Errorf("profile.DefaultHold = %v, want did:web:hold02.atcr.io (should be updated in-place)", profile.DefaultHold) 626 + } 627 + }) 461 628 } 462 629 463 630 // TestProfileRKey tests that profile record key is always "self"
+6 -6
pkg/appview/templates/pages/repository.html
··· 188 188 </label> 189 189 </div> 190 190 {{ if .Manifests }} 191 - <div class="space-y-4"> 191 + <div class="space-y-4 manifests-list"> 192 192 {{ range .Manifests }} 193 193 <div class="bg-base-200 rounded-lg p-4 space-y-3" id="manifest-{{ sanitizeID .Manifest.Digest }}" data-reachable="{{ .Reachable }}"> 194 194 <div class="flex flex-wrap items-start justify-between gap-2"> ··· 220 220 {{ else if not .Reachable }} 221 221 <span class="badge badge-sm badge-warning">{{ icon "alert-triangle" "size-3" }} Offline</span> 222 222 {{ end }} 223 - {{/* Vulnerability scan badge placeholder (batch-loaded via OOB swap) */}} 224 - {{ if and (not .IsManifestList) .Manifest.HoldEndpoint }} 225 - <span id="scan-badge-{{ trimPrefix "sha256:" .Manifest.Digest }}"></span> 226 - {{ end }} 227 223 </div> 228 224 <div class="flex items-center gap-2"> 229 225 <code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code> 230 226 <button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Manifest.Digest }}')" aria-label="Copy manifest digest to clipboard">{{ icon "copy" "size-3" }}</button> 231 227 </div> 228 + {{/* Vulnerability scan badge — own row below digest */}} 229 + {{ if and (not .IsManifestList) .Manifest.HoldEndpoint }} 230 + <div><span id="scan-badge-{{ trimPrefix "sha256:" .Manifest.Digest }}"></span></div> 231 + {{ end }} 232 232 </div> 233 233 <div class="flex items-center gap-2"> 234 234 <time class="text-sm text-base-content/60" datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> ··· 307 307 308 308 <!-- Vulnerability Details Modal --> 309 309 <dialog id="vuln-detail-modal" class="modal"> 310 - <div class="modal-box max-w-4xl"> 310 + <div class="modal-box max-w-6xl"> 311 311 <h3 class="text-lg font-bold">Vulnerability Scan Results</h3> 312 312 <div id="vuln-modal-body" class="py-4"> 313 313 <span class="loading loading-spinner loading-md"></span>
+5 -13
pkg/appview/templates/partials/vuln-badge.html
··· 4 4 {{ else if eq .Total 0 }} 5 5 <span class="badge badge-sm badge-success" title="No vulnerabilities found (scanned {{ .ScannedAt }})">{{ icon "shield-check" "size-3" }} Clean</span> 6 6 {{ else }} 7 - <button class="flex items-center gap-1 cursor-pointer hover:opacity-80 transition-opacity" 7 + <button class="vuln-strip cursor-pointer hover:opacity-80 transition-opacity" 8 8 onclick="openVulnDetails('{{ .Digest }}', '{{ .HoldEndpoint }}')" 9 9 title="Click for vulnerability details (scanned {{ .ScannedAt }})"> 10 - {{ if gt .Critical 0 }} 11 - <span class="badge badge-sm badge-error">C:{{ .Critical }}</span> 12 - {{ end }} 13 - {{ if gt .High 0 }} 14 - <span class="badge badge-sm badge-warning">H:{{ .High }}</span> 15 - {{ end }} 16 - {{ if gt .Medium 0 }} 17 - <span class="badge badge-sm badge-soft badge-warning">M:{{ .Medium }}</span> 18 - {{ end }} 19 - {{ if gt .Low 0 }} 20 - <span class="badge badge-sm badge-info">L:{{ .Low }}</span> 21 - {{ end }} 10 + <span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Critical }}</span> 11 + <span class="tooltip vuln-box-high" data-tip="High">{{ .High }}</span> 12 + <span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Medium }}</span> 13 + <span class="tooltip vuln-box-low" data-tip="Low">{{ .Low }}</span> 22 14 </button> 23 15 {{ end }} 24 16 {{ end }}
+15 -13
pkg/appview/templates/partials/vuln-details.html
··· 3 3 {{ if .Summary.Total }} 4 4 <!-- Summary available but no detailed report --> 5 5 <div class="space-y-4"> 6 - <div class="flex flex-wrap gap-2"> 7 - {{ if gt .Summary.Critical 0 }}<span class="badge badge-error">{{ .Summary.Critical }} Critical</span>{{ end }} 8 - {{ if gt .Summary.High 0 }}<span class="badge badge-warning">{{ .Summary.High }} High</span>{{ end }} 9 - {{ if gt .Summary.Medium 0 }}<span class="badge badge-soft badge-warning">{{ .Summary.Medium }} Medium</span>{{ end }} 10 - {{ if gt .Summary.Low 0 }}<span class="badge badge-info">{{ .Summary.Low }} Low</span>{{ end }} 11 - </div> 6 + <span class="vuln-strip"> 7 + <span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Summary.Critical }}</span> 8 + <span class="tooltip vuln-box-high" data-tip="High">{{ .Summary.High }}</span> 9 + <span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Summary.Medium }}</span> 10 + <span class="tooltip vuln-box-low" data-tip="Low">{{ .Summary.Low }}</span> 11 + </span> 12 12 <p class="text-base-content/60 text-sm">{{ .Error }}</p> 13 13 {{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }} 14 14 </div> ··· 18 18 {{ else }} 19 19 <div class="space-y-4"> 20 20 <!-- Summary badges --> 21 - <div class="flex flex-wrap items-center gap-2"> 21 + <div class="flex flex-wrap items-center gap-3"> 22 22 <span class="font-semibold text-sm">{{ .Summary.Total }} vulnerabilities found</span> 23 - {{ if gt .Summary.Critical 0 }}<span class="badge badge-error">{{ .Summary.Critical }} Critical</span>{{ end }} 24 - {{ if gt .Summary.High 0 }}<span class="badge badge-warning">{{ .Summary.High }} High</span>{{ end }} 25 - {{ if gt .Summary.Medium 0 }}<span class="badge badge-soft badge-warning">{{ .Summary.Medium }} Medium</span>{{ end }} 26 - {{ if gt .Summary.Low 0 }}<span class="badge badge-info">{{ .Summary.Low }} Low</span>{{ end }} 23 + <span class="vuln-strip"> 24 + <span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Summary.Critical }}</span> 25 + <span class="tooltip vuln-box-high" data-tip="High">{{ .Summary.High }}</span> 26 + <span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Summary.Medium }}</span> 27 + <span class="tooltip vuln-box-low" data-tip="Low">{{ .Summary.Low }}</span> 28 + </span> 27 29 </div> 28 30 29 31 {{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }} ··· 68 70 <span class="font-mono text-xs">{{ .Package }}</span> 69 71 {{ if .Type }}<span class="text-base-content/40 text-xs">({{ .Type }})</span>{{ end }} 70 72 </td> 71 - <td class="font-mono text-xs">{{ .Version }}</td> 72 - <td class="font-mono text-xs"> 73 + <td class="font-mono text-xs break-all">{{ .Version }}</td> 74 + <td class="font-mono text-xs break-all"> 73 75 {{ if .FixedIn }} 74 76 <span class="text-success">{{ .FixedIn }}</span> 75 77 {{ else }}
-31
pkg/atproto/lexicon.go
··· 558 558 return migrated, nil 559 559 } 560 560 561 - // ResolveHoldDIDFromURL converts a hold endpoint URL to a did:web DID 562 - // This ensures that different representations of the same hold are deduplicated: 563 - // - http://172.28.0.3:8080 → did:web:172.28.0.3:8080 564 - // - http://hold01.atcr.io → did:web:hold01.atcr.io 565 - // - https://hold01.atcr.io → did:web:hold01.atcr.io 566 - // - did:web:hold01.atcr.io → did:web:hold01.atcr.io (passthrough) 567 - func ResolveHoldDIDFromURL(holdURL string) string { 568 - // Handle empty URLs 569 - if holdURL == "" { 570 - return "" 571 - } 572 - 573 - // If already a DID, return as-is 574 - if IsDID(holdURL) { 575 - return holdURL 576 - } 577 - 578 - // Parse URL to get hostname 579 - holdURL = strings.TrimPrefix(holdURL, "http://") 580 - holdURL = strings.TrimPrefix(holdURL, "https://") 581 - holdURL = strings.TrimSuffix(holdURL, "/") 582 - 583 - // Extract hostname (remove path if present) 584 - parts := strings.Split(holdURL, "/") 585 - hostname := parts[0] 586 - 587 - // Convert to did:web 588 - // did:web uses hostname directly (port included if non-standard) 589 - return "did:web:" + hostname 590 - } 591 - 592 561 // IsDID checks if a string is a DID (starts with "did:") 593 562 func IsDID(s string) bool { 594 563 return len(s) > 4 && s[:4] == "did:"
-67
pkg/atproto/lexicon_test.go
··· 653 653 } 654 654 } 655 655 656 - func TestResolveHoldDIDFromURL(t *testing.T) { 657 - tests := []struct { 658 - name string 659 - holdURL string 660 - want string 661 - }{ 662 - { 663 - name: "https URL", 664 - holdURL: "https://hold01.atcr.io", 665 - want: "did:web:hold01.atcr.io", 666 - }, 667 - { 668 - name: "http URL", 669 - holdURL: "http://hold01.atcr.io", 670 - want: "did:web:hold01.atcr.io", 671 - }, 672 - { 673 - name: "URL with trailing slash", 674 - holdURL: "https://hold01.atcr.io/", 675 - want: "did:web:hold01.atcr.io", 676 - }, 677 - { 678 - name: "URL with path", 679 - holdURL: "https://hold01.atcr.io/some/path", 680 - want: "did:web:hold01.atcr.io", 681 - }, 682 - { 683 - name: "URL with port", 684 - holdURL: "https://hold01.atcr.io:8080", 685 - want: "did:web:hold01.atcr.io:8080", 686 - }, 687 - { 688 - name: "already a did:web", 689 - holdURL: "did:web:hold01.atcr.io", 690 - want: "did:web:hold01.atcr.io", 691 - }, 692 - { 693 - name: "already a did:plc", 694 - holdURL: "did:plc:abc123", 695 - want: "did:plc:abc123", 696 - }, 697 - { 698 - name: "empty string", 699 - holdURL: "", 700 - want: "", 701 - }, 702 - { 703 - name: "localhost", 704 - holdURL: "http://localhost:8080", 705 - want: "did:web:localhost:8080", 706 - }, 707 - { 708 - name: "IP address", 709 - holdURL: "http://192.168.1.1:8080", 710 - want: "did:web:192.168.1.1:8080", 711 - }, 712 - } 713 - 714 - for _, tt := range tests { 715 - t.Run(tt.name, func(t *testing.T) { 716 - got := ResolveHoldDIDFromURL(tt.holdURL) 717 - if got != tt.want { 718 - t.Errorf("ResolveHoldDIDFromURL() = %v, want %v", got, tt.want) 719 - } 720 - }) 721 - } 722 - } 723 656 724 657 func TestIsDID(t *testing.T) { 725 658 tests := []struct {
+52
pkg/atproto/resolver.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "io" 7 + "net/http" 6 8 "strings" 7 9 8 10 "github.com/bluesky-social/indigo/atproto/syntax" ··· 30 32 31 33 // Fallback: assume it's a hostname and use HTTPS 32 34 return "https://" + holdIdentifier, nil 35 + } 36 + 37 + // ResolveHoldDID resolves a hold identifier (DID, URL, or hostname) to its actual DID. 38 + // If the input is already a DID, it is returned as-is. 39 + // If the input is a URL or hostname, the hold's /.well-known/atproto-did endpoint is 40 + // fetched to discover the real DID (which may be did:web or did:plc). 41 + func ResolveHoldDID(ctx context.Context, holdIdentifier string) (string, error) { 42 + if holdIdentifier == "" { 43 + return "", fmt.Errorf("empty hold identifier") 44 + } 45 + 46 + // If already a DID, return as-is 47 + if IsDID(holdIdentifier) { 48 + return holdIdentifier, nil 49 + } 50 + 51 + // Normalize to a full URL 52 + holdURL := holdIdentifier 53 + if !strings.HasPrefix(holdURL, "http://") && !strings.HasPrefix(holdURL, "https://") { 54 + holdURL = "https://" + holdURL 55 + } 56 + holdURL = strings.TrimSuffix(holdURL, "/") 57 + 58 + // Fetch /.well-known/atproto-did to discover the hold's actual DID 59 + req, err := http.NewRequestWithContext(ctx, "GET", holdURL+"/.well-known/atproto-did", nil) 60 + if err != nil { 61 + return "", fmt.Errorf("failed to create request for hold DID resolution: %w", err) 62 + } 63 + 64 + resp, err := http.DefaultClient.Do(req) 65 + if err != nil { 66 + return "", fmt.Errorf("failed to fetch hold DID from %s: %w", holdURL, err) 67 + } 68 + defer resp.Body.Close() 69 + 70 + if resp.StatusCode != http.StatusOK { 71 + return "", fmt.Errorf("hold at %s returned status %d for DID resolution", holdURL, resp.StatusCode) 72 + } 73 + 74 + body, err := io.ReadAll(io.LimitReader(resp.Body, 256)) 75 + if err != nil { 76 + return "", fmt.Errorf("failed to read hold DID response: %w", err) 77 + } 78 + 79 + did := strings.TrimSpace(string(body)) 80 + if !IsDID(did) { 81 + return "", fmt.Errorf("hold at %s returned invalid DID: %q", holdURL, did) 82 + } 83 + 84 + return did, nil 33 85 } 34 86 35 87 // ResolveHoldDIDToURL resolves a hold DID to its HTTP service endpoint.
+7 -3
pkg/hold/gc/gc.go
··· 621 621 continue 622 622 } 623 623 624 - if gc.manifestBelongsToHold(&manifest, holdDID) { 624 + if gc.manifestBelongsToHold(ctx, &manifest, holdDID) { 625 625 manifests = append(manifests, &manifestInfo{ 626 626 URI: rec.URI, 627 627 UserDID: userDID, ··· 640 640 } 641 641 642 642 // manifestBelongsToHold checks if a manifest references this hold via HoldDID or legacy HoldEndpoint. 643 - func (gc *GarbageCollector) manifestBelongsToHold(manifest *atproto.ManifestRecord, holdDID string) bool { 643 + func (gc *GarbageCollector) manifestBelongsToHold(ctx context.Context, manifest *atproto.ManifestRecord, holdDID string) bool { 644 644 if manifest.HoldDID == holdDID { 645 645 return true 646 646 } 647 647 // Legacy: check holdEndpoint converted to DID 648 648 if manifest.HoldEndpoint != "" { 649 - resolved := atproto.ResolveHoldDIDFromURL(manifest.HoldEndpoint) 649 + resolved, err := atproto.ResolveHoldDID(ctx, manifest.HoldEndpoint) 650 + if err != nil { 651 + gc.logger.Debug("Failed to resolve hold DID from legacy endpoint", "holdEndpoint", manifest.HoldEndpoint, "error", err) 652 + return false 653 + } 650 654 return resolved == holdDID 651 655 } 652 656 return false
+3 -2
pkg/hold/gc/gc_test.go
··· 1 1 package gc 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "fmt" 6 7 "log/slog" ··· 200 201 } 201 202 202 203 func TestManifestBelongsToHold(t *testing.T) { 203 - gc := &GarbageCollector{} 204 + gc := &GarbageCollector{logger: newTestLogger()} 204 205 holdDID := "did:web:hold01.atcr.io" 205 206 206 207 tests := []struct { ··· 253 254 254 255 for _, tt := range tests { 255 256 t.Run(tt.name, func(t *testing.T) { 256 - got := gc.manifestBelongsToHold(tt.manifest, holdDID) 257 + got := gc.manifestBelongsToHold(context.Background(), tt.manifest, holdDID) 257 258 if got != tt.want { 258 259 t.Errorf("manifestBelongsToHold() = %v, want %v", got, tt.want) 259 260 }
+3 -5
pkg/hold/pds/xrpc.go
··· 1493 1493 "operation", operation, 1494 1494 "digest", digest) 1495 1495 slog.Debug("Using XRPC proxy fallback") 1496 - proxyURL := getProxyURL(h.pds.PublicURL, digest, did, operation) 1496 + proxyURL := getProxyURL(h.pds.PublicURL, digest, h.pds.DID(), operation) 1497 1497 if proxyURL == "" { 1498 1498 return "", fmt.Errorf("presign failed and XRPC proxy not supported for PUT operations") 1499 1499 } ··· 1504 1504 } 1505 1505 1506 1506 // Fallback: return XRPC endpoint through this service 1507 - proxyURL := getProxyURL(h.pds.PublicURL, digest, did, operation) 1507 + proxyURL := getProxyURL(h.pds.PublicURL, digest, h.pds.DID(), operation) 1508 1508 if proxyURL == "" { 1509 1509 return "", fmt.Errorf("S3 client not available and XRPC proxy not supported for PUT operations") 1510 1510 } ··· 1523 1523 // getProxyURL returns XRPC endpoint for blob operations (fallback when presigned URLs unavailable) 1524 1524 // For GET/HEAD operations, returns the XRPC getBlob endpoint 1525 1525 // For PUT operations, this fallback is no longer supported - use multipart upload instead 1526 - func getProxyURL(publicURL string, digest, did string, operation string) string { 1526 + func getProxyURL(publicURL string, digest, holdDID string, operation string) string { 1527 1527 // For read operations, use XRPC getBlob endpoint 1528 1528 if operation == http.MethodGet || operation == http.MethodHead { 1529 - // Generate hold DID from public URL using shared function 1530 - holdDID := atproto.ResolveHoldDIDFromURL(publicURL) 1531 1529 return fmt.Sprintf("%s%s?did=%s&cid=%s", 1532 1530 publicURL, atproto.SyncGetBlob, holdDID, digest) 1533 1531 }
+1 -1
pkg/hold/server.go
··· 192 192 193 193 // Initialize scan broadcaster if scanner secret is configured 194 194 if cfg.Scanner.Secret != "" { 195 - holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL) 195 + holdDID := s.PDS.DID() 196 196 var sb *pds.ScanBroadcaster 197 197 if s.holdDB != nil { 198 198 sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, s.holdDB.DB, s3Service, s.PDS)
+3 -1
scanner/internal/scan/extractor.go
··· 142 142 143 143 switch header.Typeflag { 144 144 case tar.TypeDir: 145 - if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil { 145 + // Always set owner write bit so we can create files inside (e.g. Go module 146 + // cache dirs are 0555 in images, which would block subsequent writes) 147 + if err := os.MkdirAll(target, os.FileMode(header.Mode)|0200); err != nil { 146 148 return fmt.Errorf("failed to create directory %s: %w", target, err) 147 149 } 148 150