more did:plc fixes, more vulnerability scanner fixes

This commit is contained in:
Evan Jarrett
2026-02-15 22:28:36 -06:00
parent 10b35642a5
commit 2df5377541
27 changed files with 541 additions and 316 deletions
+4
View File
@@ -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")
+16
View File
@@ -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))
+7 -6
View File
@@ -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
+30 -9
View File
@@ -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()
+34
View File
@@ -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)))
}))
+32 -19
View File
@@ -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,
})
}
+15 -1
View File
@@ -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)))
-54
View File
@@ -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)
}
})
}
}
+5 -1
View File
@@ -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] {
+9 -5
View File
@@ -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
}
+9 -6
View File
@@ -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
+30
View File
@@ -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); }
}
+3 -3
View File
@@ -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
}
+4 -2
View File
@@ -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 {
+42 -29
View File
@@ -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)
+206 -39
View File
@@ -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"
+6 -6
View File
@@ -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>
+5 -13
View File
@@ -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 }}
-31
View File
@@ -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:"
-67
View File
@@ -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 {
+52
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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)
}
+3 -5
View File
@@ -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
View File
@@ -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)
+3 -1
View File
@@ -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)
}