package storage import ( "context" "encoding/json" "errors" "fmt" "log/slog" "sync" "time" "atcr.io/pkg/atproto" "github.com/bluesky-social/indigo/atproto/syntax" ) // ProfileRKey is always "self" per lexicon const ProfileRKey = "self" // Global map to track in-flight profile migrations (DID -> true) // Used to prevent duplicate migration goroutines var migrationLocks sync.Map // EnsureProfile checks if a user's profile exists and creates it if needed. // If the profile already exists, missing fields are reconciled against the // AppView's defaults so downstream logic (e.g. successor migration) has a // concrete defaultHold to anchor on instead of relying on the empty fallback. // Currently only defaultHold is reconciled; other zero-valued fields have // their own runtime defaults and shouldn't be written unprompted. // This should be called during authentication (OAuth exchange or token service). // Expected format for defaultHoldDID: "did:web:hold01.atcr.io" func EnsureProfile(ctx context.Context, client *atproto.Client, defaultHoldDID string) error { // Resolve the AppView default to a DID up-front; used both for create and reconcile paths. normalizedDID := "" if defaultHoldDID != "" { resolved, err := atproto.ResolveHoldDID(ctx, defaultHoldDID) if err != nil { slog.Warn("Failed to resolve hold DID", "component", "profile", "defaultHold", defaultHoldDID, "error", err) } else { normalizedDID = resolved } } record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, ProfileRKey) if err != nil && !errors.Is(err, atproto.ErrRecordNotFound) { // Preserve previous best-effort behavior: log and treat as missing. slog.Warn("Failed to fetch existing profile", "component", "profile", "did", client.DID(), "error", err) record = nil } if record == nil { newProfile := atproto.NewSailorProfileRecord(normalizedDID) if _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, newProfile); err != nil { return fmt.Errorf("failed to create sailor profile: %w", err) } slog.Debug("Created sailor profile", "component", "profile", "default_hold", normalizedDID) return nil } var profile atproto.SailorProfileRecord if err := json.Unmarshal(record.Value, &profile); err != nil { return fmt.Errorf("failed to parse existing profile: %w", err) } changed := false if profile.DefaultHold == "" && normalizedDID != "" { profile.DefaultHold = normalizedDID changed = true slog.Info("Reconciling empty defaultHold to AppView default", "component", "profile", "did", client.DID(), "default_hold", normalizedDID) } if !changed { return nil } profile.UpdatedAt = time.Now() if _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, &profile); err != nil { return fmt.Errorf("failed to reconcile sailor profile: %w", err) } return nil } // GetProfile retrieves the user's profile from their PDS // Returns nil if profile doesn't exist // Automatically migrates old URL-based defaultHold values to DIDs func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorProfileRecord, error) { record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, ProfileRKey) if err != nil { // Check if it's a 404 (profile doesn't exist) if errors.Is(err, atproto.ErrRecordNotFound) { return nil, nil } return nil, fmt.Errorf("failed to get profile: %w", err) } // Parse the profile record var profile atproto.SailorProfileRecord if err := json.Unmarshal(record.Value, &profile); err != nil { return nil, fmt.Errorf("failed to parse profile: %w", err) } // Migrate old URL-based defaultHold to DID format // This ensures backward compatibility with profiles created before DID migration if _, parseErr := syntax.ParseDID(profile.DefaultHold); profile.DefaultHold != "" && parseErr != nil { // 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) }() // 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) } }() } } } return &profile, nil } // UpdateProfile updates the user's profile // Normalizes defaultHold to DID format before saving func UpdateProfile(ctx context.Context, client *atproto.Client, profile *atproto.SailorProfileRecord) error { // Normalize defaultHold to DID if it's a URL // This ensures we always store DIDs, even if user provides a URL if _, parseErr := syntax.ParseDID(profile.DefaultHold); profile.DefaultHold != "" && parseErr != nil { 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) if err != nil { return fmt.Errorf("failed to update profile: %w", err) } return nil }