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

Configure Feed

Select the types of activity you want to include in your feed.

more did:plc fixes, more vulnerability scanner fixes

+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