···364364365365// ResolveHoldDIDFromURL converts a hold endpoint URL to a did:web DID
366366// For did:web holds: https://hold01.atcr.io → did:web:hold01.atcr.io
367367+// If input is already a DID, returns it as-is
367368func ResolveHoldDIDFromURL(holdURL string) string {
368369 // Handle empty URLs
369370 if holdURL == "" {
370371 return ""
372372+ }
373373+374374+ // If already a DID, return as-is
375375+ if strings.HasPrefix(holdURL, "did:") {
376376+ return holdURL
371377 }
372378373379 // Parse URL to get hostname
+34-2
pkg/atproto/profile.go
···55 "encoding/json"
66 "errors"
77 "fmt"
88+ "sync"
99+ "time"
810)
9111012// Profile record key is always "self" per lexicon
1113const ProfileRKey = "self"
1414+1515+// Global map to track in-flight profile migrations (DID -> true)
1616+// Used to prevent duplicate migration goroutines
1717+var migrationLocks sync.Map
12181319// EnsureProfile checks if a user's profile exists and creates it if needed
1420// This should be called during authentication (OAuth exchange or token service)
···6571 // This ensures backward compatibility with profiles created before DID migration
6672 if profile.DefaultHold != "" && !isDID(profile.DefaultHold) {
6773 // Convert URL to DID transparently
6868- profile.DefaultHold = ResolveHoldDIDFromURL(profile.DefaultHold)
6969- fmt.Printf("DEBUG [profile]: Migrated defaultHold URL to DID: %s\n", profile.DefaultHold)
7474+ migratedDID := ResolveHoldDIDFromURL(profile.DefaultHold)
7575+ profile.DefaultHold = migratedDID
7676+7777+ // Persist the migration to PDS in a background goroutine
7878+ // Use a lock to ensure only one goroutine migrates this DID
7979+ did := client.did
8080+ if _, loaded := migrationLocks.LoadOrStore(did, true); !loaded {
8181+ // We got the lock - launch goroutine to persist the migration
8282+ go func() {
8383+ // Clean up lock when done (after a short delay to batch requests)
8484+ defer func() {
8585+ time.Sleep(1 * time.Second)
8686+ migrationLocks.Delete(did)
8787+ }()
8888+8989+ // Create a new context with timeout for the background operation
9090+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
9191+ defer cancel()
9292+9393+ // Update the profile on the PDS
9494+ profile.UpdatedAt = time.Now()
9595+ if err := UpdateProfile(ctx, client, &profile); err != nil {
9696+ fmt.Printf("WARNING [profile]: Failed to persist URL-to-DID migration for %s: %v\n", did, err)
9797+ } else {
9898+ fmt.Printf("DEBUG [profile]: Persisted defaultHold migration to DID: %s (for DID: %s)\n", migratedDID, did)
9999+ }
100100+ }()
101101+ }
70102 }
7110372104 return &profile, nil
-44
pkg/hold/service.go
···44 "context"
55 "fmt"
66 "log"
77- "net/url"
8798 "atcr.io/pkg/auth"
109 "github.com/aws/aws-sdk-go/service/s3"
···74737574 return service, nil
7675}
7777-7878-// GetPresignedURL is a public wrapper around getPresignedURL for use by PDS blob store
7979-func (s *HoldService) GetPresignedURL(ctx context.Context, operation PresignedURLOperation, digest string, did string) (string, error) {
8080- return s.getPresignedURL(ctx, operation, digest, did)
8181-}
8282-8383-// isAuthorizedRead checks if the given DID has read access to this hold
8484-// This is a helper wrapper around the authorizer for internal use
8585-func (s *HoldService) isAuthorizedRead(did string) bool {
8686- ctx := context.Background()
8787- allowed, err := s.authorizer.CheckReadAccess(ctx, s.pds.DID(), did)
8888- if err != nil {
8989- log.Printf("Authorization check failed: %v", err)
9090- return false
9191- }
9292- return allowed
9393-}
9494-9595-// isAuthorizedWrite checks if the given DID has write access to this hold
9696-// This is a helper wrapper around the authorizer for internal use
9797-func (s *HoldService) isAuthorizedWrite(did string) bool {
9898- ctx := context.Background()
9999- allowed, err := s.authorizer.CheckWriteAccess(ctx, s.pds.DID(), did)
100100- if err != nil {
101101- log.Printf("Authorization check failed: %v", err)
102102- return false
103103- }
104104- return allowed
105105-}
106106-107107-// extractHostname extracts the hostname from a URL
108108-func extractHostname(urlStr string) (string, error) {
109109- u, err := url.Parse(urlStr)
110110- if err != nil {
111111- return "", err
112112- }
113113- // Remove port if present
114114- hostname := u.Hostname()
115115- if hostname == "" {
116116- return "", fmt.Errorf("no hostname in URL")
117117- }
118118- return hostname, nil
119119-}
+5-15
pkg/hold/storage.go
···991010 "github.com/aws/aws-sdk-go/aws"
1111 "github.com/aws/aws-sdk-go/service/s3"
1212+1313+ "atcr.io/pkg/atproto"
1214)
13151416// atprotoBlobPath creates a per-DID storage path for ATProto blobs
···51535254// getPresignedURL generates a presigned URL for GET, HEAD, or PUT operations
5355// Distinguishes between ATProto blobs (per-DID) and OCI blobs (content-addressed)
5454-func (s *HoldService) getPresignedURL(ctx context.Context, operation PresignedURLOperation, digest string, did string) (string, error) {
5656+func (s *HoldService) GetPresignedURL(ctx context.Context, operation PresignedURLOperation, digest string, did string) (string, error) {
5557 var path string
56585759 // Determine blob type and construct appropriate path
···151153func (s *HoldService) getProxyURL(digest, did string, operation PresignedURLOperation) string {
152154 // For read operations, use XRPC getBlob endpoint
153155 if operation == OperationGet || operation == OperationHead {
154154- // Generate hold DID from public URL
155155- holdDID := s.getHoldDID()
156156+ // Generate hold DID from public URL using shared function
157157+ holdDID := atproto.ResolveHoldDIDFromURL(s.config.Server.PublicURL)
156158 return fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
157159 s.config.Server.PublicURL, holdDID, digest)
158160 }
···161163 // Clients should use multipart upload flow via com.atproto.repo.uploadBlob
162164 return ""
163165}
164164-165165-// getHoldDID generates a did:web from the hold's public URL
166166-func (s *HoldService) getHoldDID() string {
167167- // Convert URL to did:web format
168168- // https://hold01.atcr.io → did:web:hold01.atcr.io
169169- url := s.config.Server.PublicURL
170170- url = strings.TrimPrefix(url, "https://")
171171- url = strings.TrimPrefix(url, "http://")
172172- url = strings.Split(url, "/")[0] // Remove path
173173- url = strings.Split(url, ":")[0] // Remove port
174174- return fmt.Sprintf("did:web:%s", url)
175175-}