diff --git a/deploy/upcloud/provision.go b/deploy/upcloud/provision.go index d33a0a1..fad9b5b 100644 --- a/deploy/upcloud/provision.go +++ b/deploy/upcloud/provision.go @@ -328,6 +328,10 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo if appviewCreated || holdCreated { rootDir := projectRoot() + if err := runGenerate(rootDir); err != nil { + return fmt.Errorf("go generate: %w", err) + } + fmt.Println("\nBuilding locally (GOOS=linux GOARCH=amd64)...") if appviewCreated { outputPath := filepath.Join(rootDir, "bin", "atcr-appview") diff --git a/deploy/upcloud/update.go b/deploy/upcloud/update.go index 0a957e7..27420a6 100644 --- a/deploy/upcloud/update.go +++ b/deploy/upcloud/update.go @@ -114,6 +114,11 @@ func cmdUpdate(target string, withScanner bool) error { return fmt.Errorf("unknown target: %s (use: all, appview, hold)", target) } + // Run go generate before building + if err := runGenerate(rootDir); err != nil { + return fmt.Errorf("go generate: %w", err) + } + // Build all binaries locally before touching servers fmt.Println("Building locally (GOOS=linux GOARCH=amd64)...") for _, name := range toUpdate { @@ -299,6 +304,17 @@ func configValsFromState(state *InfraState) *ConfigValues { } } +// runGenerate runs go generate ./... in the given directory using host OS/arch +// (no cross-compilation env vars — generate tools must run on the build machine). +func runGenerate(dir string) error { + fmt.Println("Running go generate ./...") + cmd := exec.Command("go", "generate", "./...") + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + // buildLocal compiles a Go binary locally with cross-compilation flags for linux/amd64. func buildLocal(dir, outputPath, buildPkg string) error { fmt.Printf(" building %s...\n", filepath.Base(outputPath)) diff --git a/pkg/appview/handlers/attestation_details.go b/pkg/appview/handlers/attestation_details.go index ed67760..60703e5 100644 --- a/pkg/appview/handlers/attestation_details.go +++ b/pkg/appview/handlers/attestation_details.go @@ -119,9 +119,10 @@ func (h *AttestationDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req loggedIn := false if user := middleware.GetUser(r); user != nil && h.Refresher != nil { loggedIn = true - holdDID := atproto.ResolveHoldDIDFromURL(details[0].HoldEndpoint) - if holdDID == "" { - holdDID = details[0].HoldEndpoint // might already be a DID + holdDID, resolveErr := atproto.ResolveHoldDID(ctx, details[0].HoldEndpoint) + if resolveErr != nil { + slog.Debug("Could not resolve hold DID for service token", "holdEndpoint", details[0].HoldEndpoint, "error", resolveErr) + holdDID = details[0].HoldEndpoint // fallback: use as-is } if token, err := auth.GetOrFetchServiceToken(ctx, h.Refresher, user.DID, holdDID, user.PDSEndpoint); err == nil { serviceToken = token @@ -267,9 +268,9 @@ func fetchLayerBlob(ctx context.Context, holdEndpoint, layerDigest, serviceToken if err != nil { return nil, fmt.Errorf("could not resolve hold endpoint %s: %w", holdEndpoint, err) } - holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) - if holdDID == "" { - return nil, fmt.Errorf("could not resolve hold DID from: %s", holdEndpoint) + holdDID, err := atproto.ResolveHoldDID(ctx, holdEndpoint) + if err != nil { + return nil, fmt.Errorf("could not resolve hold DID from %s: %w", holdEndpoint, err) } // Step 1: Request presigned URL from hold diff --git a/pkg/appview/handlers/scan_result.go b/pkg/appview/handlers/scan_result.go index aa48d28..6b13b7d 100644 --- a/pkg/appview/handlers/scan_result.go +++ b/pkg/appview/handlers/scan_result.go @@ -46,9 +46,18 @@ func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Derive hold DID from endpoint URL - holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) - if holdDID == "" { + // Resolve hold identity: holdEndpoint may be a DID or URL + holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint) + if err != nil { + slog.Debug("Failed to resolve hold DID", "holdEndpoint", holdEndpoint, "error", err) + h.renderBadge(w, vulnBadgeData{Error: true}) + return + } + + // Resolve to HTTP endpoint URL (handles DID, URL, or hostname) + holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint) + if err != nil { + slog.Debug("Failed to resolve hold URL", "holdEndpoint", holdEndpoint, "error", err) h.renderBadge(w, vulnBadgeData{Error: true}) return } @@ -61,7 +70,7 @@ func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer cancel() scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", - holdEndpoint, + holdURL, url.QueryEscape(holdDID), url.QueryEscape(atproto.ScanCollection), url.QueryEscape(rkey), @@ -116,7 +125,7 @@ func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ScannedAt: scanRecord.ScannedAt, Found: true, Digest: digest, - HoldEndpoint: holdEndpoint, + HoldEndpoint: holdDID, }) } @@ -175,7 +184,7 @@ func fetchScanRecord(ctx context.Context, holdEndpoint, holdDID, hexDigest strin ScannedAt: scanRecord.ScannedAt, Found: true, Digest: fullDigest, - HoldEndpoint: holdEndpoint, + HoldEndpoint: holdDID, } } @@ -199,9 +208,21 @@ func (h *BatchScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques digests = digests[:50] } - holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) - if holdDID == "" { + holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint) + if err != nil { // Can't resolve hold — render empty OOB spans + slog.Debug("Failed to resolve hold DID for batch scan", "holdEndpoint", holdEndpoint, "error", err) + w.Header().Set("Content-Type", "text/html") + for _, d := range digests { + fmt.Fprintf(w, ``, template.HTMLEscapeString(d)) + } + return + } + + // Resolve to HTTP endpoint URL (handles DID, URL, or hostname) + holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint) + if err != nil { + slog.Debug("Failed to resolve hold URL for batch scan", "holdEndpoint", holdEndpoint, "error", err) w.Header().Set("Content-Type", "text/html") for _, d := range digests { fmt.Fprintf(w, ``, template.HTMLEscapeString(d)) @@ -229,7 +250,7 @@ func (h *BatchScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() - results[idx].data = fetchScanRecord(ctx, holdEndpoint, holdDID, hex) + results[idx].data = fetchScanRecord(ctx, holdURL, holdDID, hex) }(i, hexDigest) } wg.Wait() diff --git a/pkg/appview/handlers/scan_result_test.go b/pkg/appview/handlers/scan_result_test.go index 815b42f..5e52f29 100644 --- a/pkg/appview/handlers/scan_result_test.go +++ b/pkg/appview/handlers/scan_result_test.go @@ -11,6 +11,19 @@ import ( "atcr.io/pkg/appview/handlers" ) +// mockHoldDID is the DID returned by test hold servers for /.well-known/atproto-did +const mockHoldDID = "did:web:hold.example.com" + +// handleMockDID serves /.well-known/atproto-did for test hold servers. +// Returns true if the request was handled, false if it should be passed to the next handler. +func handleMockDID(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path == "/.well-known/atproto-did" { + w.Write([]byte(mockHoldDID)) + return true + } + return false +} + // mockScanRecord returns a getRecord JSON envelope wrapping a scan record func mockScanRecord(critical, high, medium, low, total int64) string { record := map[string]any{ @@ -51,6 +64,9 @@ func setupScanResultHandler(t *testing.T, holdURL string) *handlers.ScanResultHa func TestScanResult_WithVulnerabilities(t *testing.T) { // Mock hold that returns a scan record with vulnerabilities hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if handleMockDID(w, r) { + return + } w.Header().Set("Content-Type", "application/json") w.Write([]byte(mockScanRecord(2, 5, 10, 3, 20))) })) @@ -96,6 +112,9 @@ func TestScanResult_WithVulnerabilities(t *testing.T) { func TestScanResult_Clean(t *testing.T) { // Mock hold that returns a scan record with zero vulnerabilities hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if handleMockDID(w, r) { + return + } w.Header().Set("Content-Type", "application/json") w.Write([]byte(mockScanRecord(0, 0, 0, 0, 0))) })) @@ -124,6 +143,9 @@ func TestScanResult_Clean(t *testing.T) { func TestScanResult_NotFound(t *testing.T) { // Mock hold that returns 404 (no scan record — scanning disabled or not yet scanned) hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if handleMockDID(w, r) { + return + } http.Error(w, "record not found", http.StatusNotFound) })) defer hold.Close() @@ -145,6 +167,9 @@ func TestScanResult_NotFound(t *testing.T) { func TestScanResult_HoldError(t *testing.T) { // Mock hold that returns 500 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if handleMockDID(w, r) { + return + } http.Error(w, "internal error", http.StatusInternalServerError) })) defer hold.Close() @@ -226,6 +251,9 @@ func TestScanResult_MissingHoldEndpoint(t *testing.T) { func TestScanResult_OnlyCriticalShown(t *testing.T) { // Only critical vulns, no high/medium/low hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if handleMockDID(w, r) { + return + } w.Header().Set("Content-Type", "application/json") w.Write([]byte(mockScanRecord(3, 0, 0, 0, 3))) })) @@ -272,6 +300,9 @@ func setupBatchScanResultHandler(t *testing.T) *handlers.BatchScanResultHandler func TestBatchScanResult_MultipleDigests(t *testing.T) { // Mock hold that returns different results based on rkey hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if handleMockDID(w, r) { + return + } rkey := r.URL.Query().Get("rkey") w.Header().Set("Content-Type", "application/json") switch rkey { @@ -379,6 +410,9 @@ func TestBatchScanResult_HoldUnreachable(t *testing.T) { func TestBatchScanResult_SingleDigest(t *testing.T) { hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if handleMockDID(w, r) { + return + } w.Header().Set("Content-Type", "application/json") w.Write([]byte(mockScanRecord(1, 0, 0, 0, 1))) })) diff --git a/pkg/appview/handlers/vuln_details.go b/pkg/appview/handlers/vuln_details.go index 06b3abd..00f3c91 100644 --- a/pkg/appview/handlers/vuln_details.go +++ b/pkg/appview/handlers/vuln_details.go @@ -21,30 +21,35 @@ type VulnDetailsHandler struct { } // grypeReport is the minimal Grype JSON structure we need. +// Grype v0.107+ uses PascalCase JSON keys. type grypeReport struct { Matches []grypeMatch `json:"matches"` } type grypeMatch struct { - Vulnerability grypeVuln `json:"vulnerability"` - Artifact grypeArtifact `json:"artifact"` + Vulnerability grypeVuln `json:"Vulnerability"` + Package grypePackage `json:"Package"` } type grypeVuln struct { - ID string `json:"id"` - Severity string `json:"severity"` - Fix grypeFix `json:"fix"` + ID string `json:"ID"` + Metadata grypeMetadata `json:"Metadata"` + Fix grypeFix `json:"Fix"` +} + +type grypeMetadata struct { + Severity string `json:"Severity"` } type grypeFix struct { - Versions []string `json:"versions"` - State string `json:"state"` + Versions []string `json:"Versions"` + State string `json:"State"` } -type grypeArtifact struct { - Name string `json:"name"` - Version string `json:"version"` - Type string `json:"type"` +type grypePackage struct { + Name string `json:"Name"` + Version string `json:"Version"` + Type string `json:"Type"` } // vulnDetailsData is the template data for the vuln-details partial. @@ -92,12 +97,20 @@ func (h *VulnDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) - if holdDID == "" { + holdDID, err := atproto.ResolveHoldDID(r.Context(), holdEndpoint) + if err != nil { + slog.Debug("Failed to resolve hold DID", "holdEndpoint", holdEndpoint, "error", err) h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold identity"}) return } + // Resolve to HTTP endpoint URL (handles DID, URL, or hostname) + holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint) + if err != nil { + h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold endpoint"}) + return + } + rkey := strings.TrimPrefix(digest, "sha256:") ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) @@ -105,7 +118,7 @@ func (h *VulnDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Step 1: Fetch the scan record to get the VulnReportBlob CID scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", - holdEndpoint, + holdURL, url.QueryEscape(holdDID), url.QueryEscape(atproto.ScanCollection), url.QueryEscape(rkey), @@ -163,7 +176,7 @@ func (h *VulnDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { blobCID := scanRecord.VulnReportBlob.Ref.String() blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", - holdEndpoint, + holdURL, url.QueryEscape(holdDID), url.QueryEscape(blobCID), ) @@ -211,11 +224,11 @@ func (h *VulnDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { matches = append(matches, vulnMatch{ CVEID: m.Vulnerability.ID, CVEURL: cveURL, - Severity: m.Vulnerability.Severity, - Package: m.Artifact.Name, - Version: m.Artifact.Version, + Severity: m.Vulnerability.Metadata.Severity, + Package: m.Package.Name, + Version: m.Package.Version, FixedIn: fixedIn, - Type: m.Artifact.Type, + Type: m.Package.Type, }) } diff --git a/pkg/appview/handlers/vuln_details_test.go b/pkg/appview/handlers/vuln_details_test.go index 6950fb3..80c1bdd 100644 --- a/pkg/appview/handlers/vuln_details_test.go +++ b/pkg/appview/handlers/vuln_details_test.go @@ -159,9 +159,13 @@ func setupVulnDetailsHandler(t *testing.T) *handlers.VulnDetailsHandler { func TestVulnDetails_FullReport(t *testing.T) { grypeJSON := mockGrypeReport() - // Mock hold that serves both getRecord and getBlob + // Mock hold that serves DID resolution, getRecord, and getBlob hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path + if path == "/.well-known/atproto-did" { + w.Write([]byte("did:web:hold.example.com")) + return + } if strings.Contains(path, "getRecord") { w.Header().Set("Content-Type", "application/json") w.Write([]byte(mockScanRecordWithBlob(1, 1, 0, 1, 3))) @@ -231,6 +235,9 @@ func TestVulnDetails_FullReport(t *testing.T) { func TestVulnDetails_NoVulnReportBlob(t *testing.T) { // Mock hold returns scan record WITHOUT VulnReportBlob hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if handleMockDID(w, r) { + return + } w.Header().Set("Content-Type", "application/json") w.Write([]byte(mockScanRecordWithoutBlob(2, 5, 10, 3, 20))) })) @@ -263,6 +270,9 @@ func TestVulnDetails_NoVulnReportBlob(t *testing.T) { func TestVulnDetails_NotFound(t *testing.T) { // Mock hold returns 404 hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if handleMockDID(w, r) { + return + } http.Error(w, "not found", http.StatusNotFound) })) defer hold.Close() @@ -286,6 +296,10 @@ func TestVulnDetails_SortsBySeverity(t *testing.T) { hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path + if path == "/.well-known/atproto-did" { + w.Write([]byte("did:web:hold.example.com")) + return + } if strings.Contains(path, "getRecord") { w.Header().Set("Content-Type", "application/json") w.Write([]byte(mockScanRecordWithBlob(1, 1, 0, 1, 3))) diff --git a/pkg/appview/holdhealth/checker_test.go b/pkg/appview/holdhealth/checker_test.go index 23e57c8..98a625b 100644 --- a/pkg/appview/holdhealth/checker_test.go +++ b/pkg/appview/holdhealth/checker_test.go @@ -6,8 +6,6 @@ import ( "net/http/httptest" "testing" "time" - - "atcr.io/pkg/atproto" ) func TestNewChecker(t *testing.T) { @@ -270,55 +268,3 @@ func TestNewWorkerWithStartupDelay(t *testing.T) { } } -func TestNormalizeHoldEndpoint(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "HTTP URL", - input: "http://hold01.atcr.io", - expected: "did:web:hold01.atcr.io", - }, - { - name: "HTTPS URL", - input: "https://hold01.atcr.io", - expected: "did:web:hold01.atcr.io", - }, - { - name: "HTTP URL with port", - input: "http://172.28.0.3:8080", - expected: "did:web:172.28.0.3:8080", - }, - { - name: "HTTP URL with trailing slash", - input: "http://hold01.atcr.io/", - expected: "did:web:hold01.atcr.io", - }, - { - name: "HTTP URL with path", - input: "http://hold01.atcr.io/some/path", - expected: "did:web:hold01.atcr.io", - }, - { - name: "Already a DID", - input: "did:web:hold01.atcr.io", - expected: "did:web:hold01.atcr.io", - }, - { - name: "DID with port", - input: "did:web:172.28.0.3:8080", - expected: "did:web:172.28.0.3:8080", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := atproto.ResolveHoldDIDFromURL(tt.input) - if result != tt.expected { - t.Errorf("normalizeHoldEndpoint(%q) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} diff --git a/pkg/appview/holdhealth/worker.go b/pkg/appview/holdhealth/worker.go index 4e559e5..ab2d75d 100644 --- a/pkg/appview/holdhealth/worker.go +++ b/pkg/appview/holdhealth/worker.go @@ -127,7 +127,11 @@ func (w *Worker) refreshAllHolds(ctx context.Context) { for _, endpoint := range endpoints { // Normalize to canonical DID format - normalizedDID := atproto.ResolveHoldDIDFromURL(endpoint) + normalizedDID, err := atproto.ResolveHoldDID(ctx, endpoint) + if err != nil { + slog.Debug("Failed to resolve hold DID during health check", "endpoint", endpoint, "error", err) + continue + } // Skip if we've already seen this normalized DID if seen[normalizedDID] { diff --git a/pkg/appview/jetstream/processor.go b/pkg/appview/jetstream/processor.go index 2d74e94..3c46094 100644 --- a/pkg/appview/jetstream/processor.go +++ b/pkg/appview/jetstream/processor.go @@ -252,8 +252,12 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData // Old manifests use holdEndpoint field (URL format) - convert to DID holdDID := manifestRecord.HoldDID if holdDID == "" && manifestRecord.HoldEndpoint != "" { - // Legacy manifest - convert URL to DID - holdDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint) + // Legacy manifest - resolve URL to DID via /.well-known/atproto-did + if resolved, err := atproto.ResolveHoldDID(ctx, manifestRecord.HoldEndpoint); err != nil { + slog.Warn("Failed to resolve hold DID from legacy manifest endpoint", "holdEndpoint", manifestRecord.HoldEndpoint, "error", err) + } else { + holdDID = resolved + } } // Detect artifact type from config media type @@ -445,9 +449,9 @@ func (p *Processor) ProcessSailorProfile(ctx context.Context, did string, record } // Convert hold URL/DID to canonical DID - holdDID := atproto.ResolveHoldDIDFromURL(profileRecord.DefaultHold) - if holdDID == "" { - slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold) + holdDID, err := atproto.ResolveHoldDID(ctx, profileRecord.DefaultHold) + if err != nil { + slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold, "error", err) return nil } diff --git a/pkg/appview/server.go b/pkg/appview/server.go index 7619363..1160d67 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -352,13 +352,16 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") { slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold) - holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold) - - profile.DefaultHold = holdDID - if err := storage.UpdateProfile(ctx, client, profile); err != nil { - slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err) + if resolvedDID, resolveErr := atproto.ResolveHoldDID(ctx, profile.DefaultHold); resolveErr != nil { + slog.Warn("Failed to resolve hold DID from URL", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold, "error", resolveErr) } else { - slog.Debug("Updated profile with hold DID", "component", "appview/callback", "hold_did", holdDID) + holdDID = resolvedDID + profile.DefaultHold = holdDID + if err := storage.UpdateProfile(ctx, client, profile); err != nil { + slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err) + } else { + slog.Debug("Updated profile with hold DID", "component", "appview/callback", "hold_did", holdDID) + } } } else { holdDID = profile.DefaultHold diff --git a/pkg/appview/src/css/main.css b/pkg/appview/src/css/main.css index d1d8096..e865b3a 100644 --- a/pkg/appview/src/css/main.css +++ b/pkg/appview/src/css/main.css @@ -368,4 +368,34 @@ .menu li > form > label { @apply block w-full; } + + /* ---------------------------------------- + OFFLINE MANIFEST FILTERING + Hide offline manifests by default; + show when "Show offline images" is checked + ---------------------------------------- */ + .manifests-list > [data-reachable="false"] { + display: none; + } + + .manifests-list.show-offline > [data-reachable="false"] { + display: block; + } + + /* ---------------------------------------- + VULNERABILITY SEVERITY BOX STRIP + Docker Hub-style connected severity boxes + ---------------------------------------- */ + .vuln-strip { + @apply inline-flex items-stretch text-xs font-semibold leading-none; + } + .vuln-strip > span { + @apply px-2 py-1 min-w-[1.75rem] text-center cursor-pointer; + } + .vuln-strip > span:first-child { @apply rounded-l; } + .vuln-strip > span:last-child { @apply rounded-r; } + .vuln-box-critical { background-color: oklch(45% 0.16 20); color: oklch(97% 0.01 20); } + .vuln-box-high { background-color: oklch(58% 0.18 35); color: oklch(97% 0.01 35); } + .vuln-box-medium { background-color: oklch(72% 0.15 70); color: oklch(25% 0.05 70); } + .vuln-box-low { background-color: oklch(80% 0.1 85); color: oklch(25% 0.05 85); } } diff --git a/pkg/appview/storage/crew.go b/pkg/appview/storage/crew.go index 65ca7ff..8d77200 100644 --- a/pkg/appview/storage/crew.go +++ b/pkg/appview/storage/crew.go @@ -23,9 +23,9 @@ func EnsureCrewMembership(ctx context.Context, client *atproto.Client, refresher } // Normalize URL to DID if needed - holdDID := atproto.ResolveHoldDIDFromURL(defaultHoldDID) - if holdDID == "" { - slog.Warn("failed to resolve hold DID", "defaultHold", defaultHoldDID) + holdDID, err := atproto.ResolveHoldDID(ctx, defaultHoldDID) + if err != nil { + slog.Warn("failed to resolve hold DID", "defaultHold", defaultHoldDID, "error", err) return } diff --git a/pkg/appview/storage/drain.go b/pkg/appview/storage/drain.go index d77a231..e46e2f6 100644 --- a/pkg/appview/storage/drain.go +++ b/pkg/appview/storage/drain.go @@ -92,8 +92,10 @@ func MigrateManifestsForSuccessor( needsRewrite := false if manifest.HoldDID == oldHold { needsRewrite = true - } else if manifest.HoldEndpoint != "" && atproto.ResolveHoldDIDFromURL(manifest.HoldEndpoint) == oldHold { - needsRewrite = true + } else if manifest.HoldEndpoint != "" { + if resolvedDID, resolveErr := atproto.ResolveHoldDID(ctx, manifest.HoldEndpoint); resolveErr == nil && resolvedDID == oldHold { + needsRewrite = true + } } if !needsRewrite { diff --git a/pkg/appview/storage/profile.go b/pkg/appview/storage/profile.go index 76f6440..e1ee09e 100644 --- a/pkg/appview/storage/profile.go +++ b/pkg/appview/storage/profile.go @@ -36,7 +36,12 @@ func EnsureProfile(ctx context.Context, client *atproto.Client, defaultHoldDID s // This ensures we store DIDs consistently in new profiles normalizedDID := "" if defaultHoldDID != "" { - normalizedDID = atproto.ResolveHoldDIDFromURL(defaultHoldDID) + resolved, err := atproto.ResolveHoldDID(ctx, defaultHoldDID) + if err != nil { + slog.Warn("Failed to resolve hold DID for new profile", "component", "profile", "defaultHold", defaultHoldDID, "error", err) + } else { + normalizedDID = resolved + } } // Profile doesn't exist - create it @@ -73,34 +78,38 @@ func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorPro // Migrate old URL-based defaultHold to DID format // This ensures backward compatibility with profiles created before DID migration if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) { - // Convert URL to DID transparently - migratedDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold) - profile.DefaultHold = migratedDID + // Convert URL to DID by querying /.well-known/atproto-did + migratedDID, resolveErr := atproto.ResolveHoldDID(ctx, profile.DefaultHold) + if resolveErr != nil { + slog.Warn("Failed to resolve hold DID during profile migration", "component", "profile", "defaultHold", profile.DefaultHold, "error", resolveErr) + } else { + profile.DefaultHold = migratedDID - // Persist the migration to PDS in a background goroutine - // Use a lock to ensure only one goroutine migrates this DID - did := client.DID() - if _, loaded := migrationLocks.LoadOrStore(did, true); !loaded { - // We got the lock - launch goroutine to persist the migration - go func() { - // Clean up lock when done (after a short delay to batch requests) - defer func() { - time.Sleep(1 * time.Second) - migrationLocks.Delete(did) + // Persist the migration to PDS in a background goroutine + // Use a lock to ensure only one goroutine migrates this DID + did := client.DID() + if _, loaded := migrationLocks.LoadOrStore(did, true); !loaded { + // We got the lock - launch goroutine to persist the migration + go func() { + // Clean up lock when done (after a short delay to batch requests) + defer func() { + time.Sleep(1 * time.Second) + migrationLocks.Delete(did) + }() + + // Create a new context with timeout for the background operation + bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Update the profile on the PDS + profile.UpdatedAt = time.Now() + if err := UpdateProfile(bgCtx, client, &profile); err != nil { + slog.Warn("Failed to persist URL-to-DID migration", "component", "profile", "did", did, "error", err) + } else { + slog.Debug("Persisted defaultHold migration to DID", "component", "profile", "migrated_did", migratedDID, "did", did) + } }() - - // Create a new context with timeout for the background operation - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Update the profile on the PDS - profile.UpdatedAt = time.Now() - if err := UpdateProfile(ctx, client, &profile); err != nil { - slog.Warn("Failed to persist URL-to-DID migration", "component", "profile", "did", did, "error", err) - } else { - slog.Debug("Persisted defaultHold migration to DID", "component", "profile", "migrated_did", migratedDID, "did", did) - } - }() + } } } @@ -113,8 +122,12 @@ func UpdateProfile(ctx context.Context, client *atproto.Client, profile *atproto // Normalize defaultHold to DID if it's a URL // This ensures we always store DIDs, even if user provides a URL if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) { - profile.DefaultHold = atproto.ResolveHoldDIDFromURL(profile.DefaultHold) - slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", profile.DefaultHold) + if resolved, err := atproto.ResolveHoldDID(ctx, profile.DefaultHold); err != nil { + slog.Warn("Failed to resolve hold DID during profile update", "component", "profile", "defaultHold", profile.DefaultHold, "error", err) + } else { + profile.DefaultHold = resolved + slog.Debug("Normalized defaultHold to DID", "component", "profile", "default_hold", profile.DefaultHold) + } } _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, profile) diff --git a/pkg/appview/storage/profile_test.go b/pkg/appview/storage/profile_test.go index 31410f5..44d3236 100644 --- a/pkg/appview/storage/profile_test.go +++ b/pkg/appview/storage/profile_test.go @@ -3,6 +3,7 @@ package storage import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -25,11 +26,6 @@ func TestEnsureProfile_Create(t *testing.T) { defaultHoldDID: "did:web:hold01.atcr.io", wantNormalized: "did:web:hold01.atcr.io", }, - { - name: "with URL - should normalize to DID", - defaultHoldDID: "https://hold01.atcr.io", - wantNormalized: "did:web:hold01.atcr.io", - }, { name: "empty default hold", defaultHoldDID: "", @@ -104,6 +100,65 @@ func TestEnsureProfile_Create(t *testing.T) { } }) } + + // URL normalization test uses a local test server for /.well-known/atproto-did + t.Run("with URL - should normalize to DID", func(t *testing.T) { + var createdProfile *atproto.SailorProfileRecord + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle hold DID resolution + if r.URL.Path == "/.well-known/atproto-did" { + w.Write([]byte("did:web:hold01.atcr.io")) + return + } + + // GetRecord: profile doesn't exist + if r.Method == "GET" { + w.WriteHeader(http.StatusNotFound) + return + } + + // PutRecord: create profile + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + + recordData := body["record"].(map[string]any) + defaultHold := recordData["defaultHold"] + defaultHoldStr := "" + if defaultHold != nil { + defaultHoldStr = defaultHold.(string) + } + if defaultHoldStr != "did:web:hold01.atcr.io" { + t.Errorf("defaultHold = %v, want did:web:hold01.atcr.io", defaultHoldStr) + } + + profileBytes, _ := json.Marshal(recordData) + json.Unmarshal(profileBytes, &createdProfile) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) + return + } + + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") + err := EnsureProfile(context.Background(), client, server.URL) + if err != nil { + t.Fatalf("EnsureProfile() error = %v", err) + } + + if createdProfile == nil { + t.Fatal("Profile was not created") + } + + if createdProfile.DefaultHold != "did:web:hold01.atcr.io" { + t.Errorf("DefaultHold = %v, want did:web:hold01.atcr.io", createdProfile.DefaultHold) + } + }) } // TestEnsureProfile_Exists tests that EnsureProfile doesn't recreate existing profiles @@ -178,24 +233,6 @@ func TestGetProfile(t *testing.T) { expectMigration: false, expectedHoldDID: "did:web:hold01.atcr.io", }, - { - name: "profile with URL (migration needed)", - serverResponse: `{ - "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", - "value": { - "$type": "io.atcr.sailor.profile", - "defaultHold": "https://hold01.atcr.io", - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T00:00:00Z" - } - }`, - serverStatus: http.StatusOK, - wantNil: false, - wantErr: false, - expectMigration: true, - originalHoldURL: "https://hold01.atcr.io", - expectedHoldDID: "did:web:hold01.atcr.io", - }, { name: "profile doesn't exist - return nil", serverResponse: "", @@ -293,6 +330,87 @@ func TestGetProfile(t *testing.T) { } }) } + + // URL migration test uses a local test server for /.well-known/atproto-did + t.Run("profile with URL (migration needed)", func(t *testing.T) { + migrationLocks = sync.Map{} + + var mu sync.Mutex + putRecordCalled := false + var migrationRequest map[string]any + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle hold DID resolution + if r.URL.Path == "/.well-known/atproto-did" { + w.Write([]byte("did:web:hold01.atcr.io")) + return + } + + // GetRecord - return profile with URL pointing to this server + if r.Method == "GET" { + response := fmt.Sprintf(`{ + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", + "value": { + "$type": "io.atcr.sailor.profile", + "defaultHold": %q, + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T00:00:00Z" + } + }`, server.URL) + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + return + } + + // PutRecord (migration) + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { + mu.Lock() + putRecordCalled = true + json.NewDecoder(r.Body).Decode(&migrationRequest) + mu.Unlock() + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) + return + } + })) + defer server.Close() + + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") + profile, err := GetProfile(context.Background(), client) + + if err != nil { + t.Fatalf("GetProfile() error = %v", err) + } + + if profile == nil { + t.Fatal("GetProfile() returned nil, want profile") + } + + if profile.DefaultHold != "did:web:hold01.atcr.io" { + t.Errorf("DefaultHold = %v, want did:web:hold01.atcr.io", profile.DefaultHold) + } + + // Give migration goroutine time to execute + time.Sleep(50 * time.Millisecond) + + mu.Lock() + called := putRecordCalled + request := migrationRequest + mu.Unlock() + + if !called { + t.Error("Expected migration PutRecord to be called") + } + + if request != nil { + recordData := request["record"].(map[string]any) + migratedHold := recordData["defaultHold"] + if migratedHold != "did:web:hold01.atcr.io" { + t.Errorf("Migrated defaultHold = %v, want did:web:hold01.atcr.io", migratedHold) + } + } + }) } // TestGetProfile_MigrationLocking tests that concurrent migrations don't happen @@ -303,18 +421,25 @@ func TestGetProfile_MigrationLocking(t *testing.T) { putRecordCount := 0 var mu sync.Mutex - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // GetRecord - return profile with URL + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle hold DID resolution + if r.URL.Path == "/.well-known/atproto-did" { + w.Write([]byte("did:web:hold01.atcr.io")) + return + } + + // GetRecord - return profile with URL pointing to this server if r.Method == "GET" { - response := `{ + response := fmt.Sprintf(`{ "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", "value": { "$type": "io.atcr.sailor.profile", - "defaultHold": "https://hold01.atcr.io", + "defaultHold": %q, "createdAt": "2025-01-01T00:00:00Z", "updatedAt": "2025-01-01T00:00:00Z" } - }` + }`, server.URL) w.WriteHeader(http.StatusOK) w.Write([]byte(response)) return @@ -383,17 +508,6 @@ func TestUpdateProfile(t *testing.T) { wantNormalized: "did:web:hold02.atcr.io", wantErr: false, }, - { - name: "update with URL - should normalize", - profile: &atproto.SailorProfileRecord{ - Type: atproto.SailorProfileCollection, - DefaultHold: "https://hold02.atcr.io", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - wantNormalized: "did:web:hold02.atcr.io", - wantErr: false, - }, { name: "clear default hold", profile: &atproto.SailorProfileRecord{ @@ -458,6 +572,59 @@ func TestUpdateProfile(t *testing.T) { } }) } + + // URL normalization test uses a local test server for /.well-known/atproto-did + t.Run("update with URL - should normalize", func(t *testing.T) { + var sentProfile map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle hold DID resolution + if r.URL.Path == "/.well-known/atproto-did" { + w.Write([]byte("did:web:hold02.atcr.io")) + return + } + + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + sentProfile = body + + if body["rkey"] != ProfileRKey { + t.Errorf("rkey = %v, want %v", body["rkey"], ProfileRKey) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) + return + } + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + profile := &atproto.SailorProfileRecord{ + Type: atproto.SailorProfileCollection, + DefaultHold: server.URL, // URL pointing to test server with /.well-known/atproto-did + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") + err := UpdateProfile(context.Background(), client, profile) + if err != nil { + t.Errorf("UpdateProfile() error = %v", err) + return + } + + recordData := sentProfile["record"].(map[string]any) + defaultHold := recordData["defaultHold"].(string) + if defaultHold != "did:web:hold02.atcr.io" { + t.Errorf("defaultHold = %v, want did:web:hold02.atcr.io", defaultHold) + } + + if profile.DefaultHold != "did:web:hold02.atcr.io" { + t.Errorf("profile.DefaultHold = %v, want did:web:hold02.atcr.io (should be updated in-place)", profile.DefaultHold) + } + }) } // TestProfileRKey tests that profile record key is always "self" diff --git a/pkg/appview/templates/pages/repository.html b/pkg/appview/templates/pages/repository.html index 59cbbb5..3a33ccc 100644 --- a/pkg/appview/templates/pages/repository.html +++ b/pkg/appview/templates/pages/repository.html @@ -188,7 +188,7 @@ {{ if .Manifests }} -
{{ .Manifest.Digest }}