mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 23:36:57 +00:00
163 lines
6.2 KiB
Go
163 lines
6.2 KiB
Go
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
|
|
}
|