mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 22:06:52 +00:00
more did:plc fixes, more vulnerability scanner fixes
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, 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, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, 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()
|
||||
|
||||
@@ -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)))
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
</label>
|
||||
</div>
|
||||
{{ if .Manifests }}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4 manifests-list">
|
||||
{{ range .Manifests }}
|
||||
<div class="bg-base-200 rounded-lg p-4 space-y-3" id="manifest-{{ sanitizeID .Manifest.Digest }}" data-reachable="{{ .Reachable }}">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
@@ -220,15 +220,15 @@
|
||||
{{ else if not .Reachable }}
|
||||
<span class="badge badge-sm badge-warning">{{ icon "alert-triangle" "size-3" }} Offline</span>
|
||||
{{ end }}
|
||||
{{/* Vulnerability scan badge placeholder (batch-loaded via OOB swap) */}}
|
||||
{{ if and (not .IsManifestList) .Manifest.HoldEndpoint }}
|
||||
<span id="scan-badge-{{ trimPrefix "sha256:" .Manifest.Digest }}"></span>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code>
|
||||
<button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Manifest.Digest }}')" aria-label="Copy manifest digest to clipboard">{{ icon "copy" "size-3" }}</button>
|
||||
</div>
|
||||
{{/* Vulnerability scan badge — own row below digest */}}
|
||||
{{ if and (not .IsManifestList) .Manifest.HoldEndpoint }}
|
||||
<div><span id="scan-badge-{{ trimPrefix "sha256:" .Manifest.Digest }}"></span></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<time class="text-sm text-base-content/60" datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
@@ -307,7 +307,7 @@
|
||||
|
||||
<!-- Vulnerability Details Modal -->
|
||||
<dialog id="vuln-detail-modal" class="modal">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<div class="modal-box max-w-6xl">
|
||||
<h3 class="text-lg font-bold">Vulnerability Scan Results</h3>
|
||||
<div id="vuln-modal-body" class="py-4">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
|
||||
@@ -4,21 +4,13 @@
|
||||
{{ else if eq .Total 0 }}
|
||||
<span class="badge badge-sm badge-success" title="No vulnerabilities found (scanned {{ .ScannedAt }})">{{ icon "shield-check" "size-3" }} Clean</span>
|
||||
{{ else }}
|
||||
<button class="flex items-center gap-1 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
<button class="vuln-strip cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onclick="openVulnDetails('{{ .Digest }}', '{{ .HoldEndpoint }}')"
|
||||
title="Click for vulnerability details (scanned {{ .ScannedAt }})">
|
||||
{{ if gt .Critical 0 }}
|
||||
<span class="badge badge-sm badge-error">C:{{ .Critical }}</span>
|
||||
{{ end }}
|
||||
{{ if gt .High 0 }}
|
||||
<span class="badge badge-sm badge-warning">H:{{ .High }}</span>
|
||||
{{ end }}
|
||||
{{ if gt .Medium 0 }}
|
||||
<span class="badge badge-sm badge-soft badge-warning">M:{{ .Medium }}</span>
|
||||
{{ end }}
|
||||
{{ if gt .Low 0 }}
|
||||
<span class="badge badge-sm badge-info">L:{{ .Low }}</span>
|
||||
{{ end }}
|
||||
<span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Critical }}</span>
|
||||
<span class="tooltip vuln-box-high" data-tip="High">{{ .High }}</span>
|
||||
<span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Medium }}</span>
|
||||
<span class="tooltip vuln-box-low" data-tip="Low">{{ .Low }}</span>
|
||||
</button>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
{{ if .Summary.Total }}
|
||||
<!-- Summary available but no detailed report -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{ if gt .Summary.Critical 0 }}<span class="badge badge-error">{{ .Summary.Critical }} Critical</span>{{ end }}
|
||||
{{ if gt .Summary.High 0 }}<span class="badge badge-warning">{{ .Summary.High }} High</span>{{ end }}
|
||||
{{ if gt .Summary.Medium 0 }}<span class="badge badge-soft badge-warning">{{ .Summary.Medium }} Medium</span>{{ end }}
|
||||
{{ if gt .Summary.Low 0 }}<span class="badge badge-info">{{ .Summary.Low }} Low</span>{{ end }}
|
||||
</div>
|
||||
<span class="vuln-strip">
|
||||
<span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Summary.Critical }}</span>
|
||||
<span class="tooltip vuln-box-high" data-tip="High">{{ .Summary.High }}</span>
|
||||
<span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Summary.Medium }}</span>
|
||||
<span class="tooltip vuln-box-low" data-tip="Low">{{ .Summary.Low }}</span>
|
||||
</span>
|
||||
<p class="text-base-content/60 text-sm">{{ .Error }}</p>
|
||||
{{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
</div>
|
||||
@@ -18,12 +18,14 @@
|
||||
{{ else }}
|
||||
<div class="space-y-4">
|
||||
<!-- Summary badges -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="font-semibold text-sm">{{ .Summary.Total }} vulnerabilities found</span>
|
||||
{{ if gt .Summary.Critical 0 }}<span class="badge badge-error">{{ .Summary.Critical }} Critical</span>{{ end }}
|
||||
{{ if gt .Summary.High 0 }}<span class="badge badge-warning">{{ .Summary.High }} High</span>{{ end }}
|
||||
{{ if gt .Summary.Medium 0 }}<span class="badge badge-soft badge-warning">{{ .Summary.Medium }} Medium</span>{{ end }}
|
||||
{{ if gt .Summary.Low 0 }}<span class="badge badge-info">{{ .Summary.Low }} Low</span>{{ end }}
|
||||
<span class="vuln-strip">
|
||||
<span class="tooltip vuln-box-critical" data-tip="Critical">{{ .Summary.Critical }}</span>
|
||||
<span class="tooltip vuln-box-high" data-tip="High">{{ .Summary.High }}</span>
|
||||
<span class="tooltip vuln-box-medium" data-tip="Medium">{{ .Summary.Medium }}</span>
|
||||
<span class="tooltip vuln-box-low" data-tip="Low">{{ .Summary.Low }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }}
|
||||
@@ -68,8 +70,8 @@
|
||||
<span class="font-mono text-xs">{{ .Package }}</span>
|
||||
{{ if .Type }}<span class="text-base-content/40 text-xs">({{ .Type }})</span>{{ end }}
|
||||
</td>
|
||||
<td class="font-mono text-xs">{{ .Version }}</td>
|
||||
<td class="font-mono text-xs">
|
||||
<td class="font-mono text-xs break-all">{{ .Version }}</td>
|
||||
<td class="font-mono text-xs break-all">
|
||||
{{ if .FixedIn }}
|
||||
<span class="text-success">{{ .FixedIn }}</span>
|
||||
{{ else }}
|
||||
|
||||
@@ -558,37 +558,6 @@ func MigrateStarRecords(ctx context.Context, client *Client) (int, error) {
|
||||
return migrated, nil
|
||||
}
|
||||
|
||||
// ResolveHoldDIDFromURL converts a hold endpoint URL to a did:web DID
|
||||
// This ensures that different representations of the same hold are deduplicated:
|
||||
// - http://172.28.0.3:8080 → did:web:172.28.0.3:8080
|
||||
// - http://hold01.atcr.io → did:web:hold01.atcr.io
|
||||
// - https://hold01.atcr.io → did:web:hold01.atcr.io
|
||||
// - did:web:hold01.atcr.io → did:web:hold01.atcr.io (passthrough)
|
||||
func ResolveHoldDIDFromURL(holdURL string) string {
|
||||
// Handle empty URLs
|
||||
if holdURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If already a DID, return as-is
|
||||
if IsDID(holdURL) {
|
||||
return holdURL
|
||||
}
|
||||
|
||||
// Parse URL to get hostname
|
||||
holdURL = strings.TrimPrefix(holdURL, "http://")
|
||||
holdURL = strings.TrimPrefix(holdURL, "https://")
|
||||
holdURL = strings.TrimSuffix(holdURL, "/")
|
||||
|
||||
// Extract hostname (remove path if present)
|
||||
parts := strings.Split(holdURL, "/")
|
||||
hostname := parts[0]
|
||||
|
||||
// Convert to did:web
|
||||
// did:web uses hostname directly (port included if non-standard)
|
||||
return "did:web:" + hostname
|
||||
}
|
||||
|
||||
// IsDID checks if a string is a DID (starts with "did:")
|
||||
func IsDID(s string) bool {
|
||||
return len(s) > 4 && s[:4] == "did:"
|
||||
|
||||
@@ -653,73 +653,6 @@ func TestParseStarRecordKey_Invalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHoldDIDFromURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
holdURL string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "https URL",
|
||||
holdURL: "https://hold01.atcr.io",
|
||||
want: "did:web:hold01.atcr.io",
|
||||
},
|
||||
{
|
||||
name: "http URL",
|
||||
holdURL: "http://hold01.atcr.io",
|
||||
want: "did:web:hold01.atcr.io",
|
||||
},
|
||||
{
|
||||
name: "URL with trailing slash",
|
||||
holdURL: "https://hold01.atcr.io/",
|
||||
want: "did:web:hold01.atcr.io",
|
||||
},
|
||||
{
|
||||
name: "URL with path",
|
||||
holdURL: "https://hold01.atcr.io/some/path",
|
||||
want: "did:web:hold01.atcr.io",
|
||||
},
|
||||
{
|
||||
name: "URL with port",
|
||||
holdURL: "https://hold01.atcr.io:8080",
|
||||
want: "did:web:hold01.atcr.io:8080",
|
||||
},
|
||||
{
|
||||
name: "already a did:web",
|
||||
holdURL: "did:web:hold01.atcr.io",
|
||||
want: "did:web:hold01.atcr.io",
|
||||
},
|
||||
{
|
||||
name: "already a did:plc",
|
||||
holdURL: "did:plc:abc123",
|
||||
want: "did:plc:abc123",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
holdURL: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "localhost",
|
||||
holdURL: "http://localhost:8080",
|
||||
want: "did:web:localhost:8080",
|
||||
},
|
||||
{
|
||||
name: "IP address",
|
||||
holdURL: "http://192.168.1.1:8080",
|
||||
want: "did:web:192.168.1.1:8080",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ResolveHoldDIDFromURL(tt.holdURL)
|
||||
if got != tt.want {
|
||||
t.Errorf("ResolveHoldDIDFromURL() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDID(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -3,6 +3,8 @@ package atproto
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
@@ -32,6 +34,56 @@ func ResolveHoldURL(ctx context.Context, holdIdentifier string) (string, error)
|
||||
return "https://" + holdIdentifier, nil
|
||||
}
|
||||
|
||||
// ResolveHoldDID resolves a hold identifier (DID, URL, or hostname) to its actual DID.
|
||||
// If the input is already a DID, it is returned as-is.
|
||||
// If the input is a URL or hostname, the hold's /.well-known/atproto-did endpoint is
|
||||
// fetched to discover the real DID (which may be did:web or did:plc).
|
||||
func ResolveHoldDID(ctx context.Context, holdIdentifier string) (string, error) {
|
||||
if holdIdentifier == "" {
|
||||
return "", fmt.Errorf("empty hold identifier")
|
||||
}
|
||||
|
||||
// If already a DID, return as-is
|
||||
if IsDID(holdIdentifier) {
|
||||
return holdIdentifier, nil
|
||||
}
|
||||
|
||||
// Normalize to a full URL
|
||||
holdURL := holdIdentifier
|
||||
if !strings.HasPrefix(holdURL, "http://") && !strings.HasPrefix(holdURL, "https://") {
|
||||
holdURL = "https://" + holdURL
|
||||
}
|
||||
holdURL = strings.TrimSuffix(holdURL, "/")
|
||||
|
||||
// Fetch /.well-known/atproto-did to discover the hold's actual DID
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", holdURL+"/.well-known/atproto-did", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request for hold DID resolution: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch hold DID from %s: %w", holdURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("hold at %s returned status %d for DID resolution", holdURL, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read hold DID response: %w", err)
|
||||
}
|
||||
|
||||
did := strings.TrimSpace(string(body))
|
||||
if !IsDID(did) {
|
||||
return "", fmt.Errorf("hold at %s returned invalid DID: %q", holdURL, did)
|
||||
}
|
||||
|
||||
return did, nil
|
||||
}
|
||||
|
||||
// ResolveHoldDIDToURL resolves a hold DID to its HTTP service endpoint.
|
||||
// Prefers the #atcr_hold service endpoint, falls back to #atproto_pds.
|
||||
// Uses the shared identity directory with cache TTL and event-driven invalidation.
|
||||
|
||||
+7
-3
@@ -621,7 +621,7 @@ func (gc *GarbageCollector) fetchUserManifestsFromEndpoint(ctx context.Context,
|
||||
continue
|
||||
}
|
||||
|
||||
if gc.manifestBelongsToHold(&manifest, holdDID) {
|
||||
if gc.manifestBelongsToHold(ctx, &manifest, holdDID) {
|
||||
manifests = append(manifests, &manifestInfo{
|
||||
URI: rec.URI,
|
||||
UserDID: userDID,
|
||||
@@ -640,13 +640,17 @@ func (gc *GarbageCollector) fetchUserManifestsFromEndpoint(ctx context.Context,
|
||||
}
|
||||
|
||||
// manifestBelongsToHold checks if a manifest references this hold via HoldDID or legacy HoldEndpoint.
|
||||
func (gc *GarbageCollector) manifestBelongsToHold(manifest *atproto.ManifestRecord, holdDID string) bool {
|
||||
func (gc *GarbageCollector) manifestBelongsToHold(ctx context.Context, manifest *atproto.ManifestRecord, holdDID string) bool {
|
||||
if manifest.HoldDID == holdDID {
|
||||
return true
|
||||
}
|
||||
// Legacy: check holdEndpoint converted to DID
|
||||
if manifest.HoldEndpoint != "" {
|
||||
resolved := atproto.ResolveHoldDIDFromURL(manifest.HoldEndpoint)
|
||||
resolved, err := atproto.ResolveHoldDID(ctx, manifest.HoldEndpoint)
|
||||
if err != nil {
|
||||
gc.logger.Debug("Failed to resolve hold DID from legacy endpoint", "holdEndpoint", manifest.HoldEndpoint, "error", err)
|
||||
return false
|
||||
}
|
||||
return resolved == holdDID
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -200,7 +201,7 @@ func TestConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestManifestBelongsToHold(t *testing.T) {
|
||||
gc := &GarbageCollector{}
|
||||
gc := &GarbageCollector{logger: newTestLogger()}
|
||||
holdDID := "did:web:hold01.atcr.io"
|
||||
|
||||
tests := []struct {
|
||||
@@ -253,7 +254,7 @@ func TestManifestBelongsToHold(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := gc.manifestBelongsToHold(tt.manifest, holdDID)
|
||||
got := gc.manifestBelongsToHold(context.Background(), tt.manifest, holdDID)
|
||||
if got != tt.want {
|
||||
t.Errorf("manifestBelongsToHold() = %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
@@ -1493,7 +1493,7 @@ func (h *XRPCHandler) GetPresignedURL(ctx context.Context, operation string, dig
|
||||
"operation", operation,
|
||||
"digest", digest)
|
||||
slog.Debug("Using XRPC proxy fallback")
|
||||
proxyURL := getProxyURL(h.pds.PublicURL, digest, did, operation)
|
||||
proxyURL := getProxyURL(h.pds.PublicURL, digest, h.pds.DID(), operation)
|
||||
if proxyURL == "" {
|
||||
return "", fmt.Errorf("presign failed and XRPC proxy not supported for PUT operations")
|
||||
}
|
||||
@@ -1504,7 +1504,7 @@ func (h *XRPCHandler) GetPresignedURL(ctx context.Context, operation string, dig
|
||||
}
|
||||
|
||||
// Fallback: return XRPC endpoint through this service
|
||||
proxyURL := getProxyURL(h.pds.PublicURL, digest, did, operation)
|
||||
proxyURL := getProxyURL(h.pds.PublicURL, digest, h.pds.DID(), operation)
|
||||
if proxyURL == "" {
|
||||
return "", fmt.Errorf("S3 client not available and XRPC proxy not supported for PUT operations")
|
||||
}
|
||||
@@ -1523,11 +1523,9 @@ func atprotoBlobPath(did, cid string) string {
|
||||
// getProxyURL returns XRPC endpoint for blob operations (fallback when presigned URLs unavailable)
|
||||
// For GET/HEAD operations, returns the XRPC getBlob endpoint
|
||||
// For PUT operations, this fallback is no longer supported - use multipart upload instead
|
||||
func getProxyURL(publicURL string, digest, did string, operation string) string {
|
||||
func getProxyURL(publicURL string, digest, holdDID string, operation string) string {
|
||||
// For read operations, use XRPC getBlob endpoint
|
||||
if operation == http.MethodGet || operation == http.MethodHead {
|
||||
// Generate hold DID from public URL using shared function
|
||||
holdDID := atproto.ResolveHoldDIDFromURL(publicURL)
|
||||
return fmt.Sprintf("%s%s?did=%s&cid=%s",
|
||||
publicURL, atproto.SyncGetBlob, holdDID, digest)
|
||||
}
|
||||
|
||||
+1
-1
@@ -192,7 +192,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
|
||||
// Initialize scan broadcaster if scanner secret is configured
|
||||
if cfg.Scanner.Secret != "" {
|
||||
holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL)
|
||||
holdDID := s.PDS.DID()
|
||||
var sb *pds.ScanBroadcaster
|
||||
if s.holdDB != nil {
|
||||
sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, s.holdDB.DB, s3Service, s.PDS)
|
||||
|
||||
@@ -142,7 +142,9 @@ func extractTarGz(tarGzPath, destDir string) error {
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil {
|
||||
// Always set owner write bit so we can create files inside (e.g. Go module
|
||||
// cache dirs are 0555 in images, which would block subsequent writes)
|
||||
if err := os.MkdirAll(target, os.FileMode(header.Mode)|0200); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", target, err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user