package atproto import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "strings" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" appbsky "github.com/bluesky-social/indigo/api/bsky" "github.com/bluesky-social/indigo/atproto/atclient" indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/bluesky-social/indigo/xrpc" ) // Sentinel errors var ( ErrRecordNotFound = errors.New("record not found") ) // RateLimitError indicates that the upstream PDS returned 429 (RateLimitExceeded). // It carries an optional RetryAfter duration derived from PDS rate-limit headers // so callers can surface it to clients (e.g., as a Retry-After response header). type RateLimitError struct { Wrapped error RetryAfter time.Duration // 0 if unknown } func (e *RateLimitError) Error() string { if e.Wrapped != nil { return e.Wrapped.Error() } return "rate limit exceeded" } func (e *RateLimitError) Unwrap() error { return e.Wrapped } // asRateLimitError inspects err and, if it represents a 429 from the PDS, // returns a *RateLimitError wrapping it. Returns nil otherwise. func asRateLimitError(err error) *RateLimitError { if err == nil { return nil } var xrpcErr *xrpc.Error if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == http.StatusTooManyRequests { var retryAfter time.Duration if xrpcErr.Ratelimit != nil && !xrpcErr.Ratelimit.Reset.IsZero() { if d := time.Until(xrpcErr.Ratelimit.Reset); d > 0 { retryAfter = d } } return &RateLimitError{Wrapped: err, RetryAfter: retryAfter} } var apiErr *atclient.APIError if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests { return &RateLimitError{Wrapped: err} } return nil } // ClientProvider abstracts OAuth vs Basic Auth client creation. // This allows the same code path for all PDS operations regardless of auth type. type ClientProvider interface { // DoWithClient executes fn with a configured lexicon client. // For OAuth: uses session.APIClient() with DPoP handling // For Basic Auth: uses xrpc.Client with bearer token DoWithClient(ctx context.Context, did string, fn func(client lexutil.LexClient) error) error } // OAuthClientProvider wraps a SessionProvider to provide a lexicon client type OAuthClientProvider struct { sessionProvider SessionProvider } // NewOAuthClientProvider creates a ClientProvider that uses OAuth sessions func NewOAuthClientProvider(sp SessionProvider) *OAuthClientProvider { return &OAuthClientProvider{sessionProvider: sp} } // DoWithClient executes fn with an OAuth session's APIClient func (p *OAuthClientProvider) DoWithClient(ctx context.Context, did string, fn func(lexutil.LexClient) error) error { return p.sessionProvider.DoWithSession(ctx, did, func(session *indigo_oauth.ClientSession) error { return fn(session.APIClient()) }) } // BasicAuthClientProvider provides an xrpc.Client for Basic Auth (app passwords) type BasicAuthClientProvider struct { pdsEndpoint string accessToken string did string } // NewBasicAuthClientProvider creates a ClientProvider that uses app passwords func NewBasicAuthClientProvider(pdsEndpoint, did, accessToken string) *BasicAuthClientProvider { return &BasicAuthClientProvider{ pdsEndpoint: pdsEndpoint, accessToken: accessToken, did: did, } } // DoWithClient executes fn with an xrpc.Client configured for Basic Auth func (p *BasicAuthClientProvider) DoWithClient(ctx context.Context, did string, fn func(lexutil.LexClient) error) error { client := &xrpc.Client{ Host: p.pdsEndpoint, Client: &http.Client{Timeout: 30 * time.Second}, } // Only set Auth if we have a token (empty token = unauthenticated request) if p.accessToken != "" { client.Auth = &xrpc.AuthInfo{ AccessJwt: p.accessToken, Did: p.did, } } return fn(client) } // SessionProvider provides locked OAuth sessions for PDS operations. // This interface allows the ATProto client to use DoWithSession() for each PDS call, // preventing DPoP nonce race conditions during concurrent operations. type SessionProvider interface { // DoWithSession executes fn with a locked OAuth session. // The lock is held for the entire duration, serializing DPoP nonce updates. DoWithSession(ctx context.Context, did string, fn func(session *indigo_oauth.ClientSession) error) error } // Client wraps ATProto operations for the registry type Client struct { pdsEndpoint string did string clientProvider ClientProvider // Unified provider for OAuth or Basic Auth httpClient *http.Client // Used for methods not yet migrated to indigo } // NewClient creates a new ATProto client for Basic Auth tokens (app passwords) func NewClient(pdsEndpoint, did, accessToken string) *Client { return &Client{ pdsEndpoint: pdsEndpoint, did: did, clientProvider: NewBasicAuthClientProvider(pdsEndpoint, did, accessToken), httpClient: &http.Client{Timeout: 30 * time.Second}, } } // NewClientWithSessionProvider creates an ATProto client that uses locked OAuth sessions. // This is the preferred constructor for concurrent operations (e.g., Docker layer uploads) // as it prevents DPoP nonce race conditions by serializing PDS calls per-DID. // // Each PDS call acquires a per-DID lock, ensuring that: // - Only one goroutine at a time can negotiate DPoP nonces with the PDS // - The session's nonce is saved to DB before other goroutines load it // - Concurrent manifest operations don't cause nonce thrashing func NewClientWithSessionProvider(pdsEndpoint, did string, sessionProvider SessionProvider) *Client { return &Client{ pdsEndpoint: pdsEndpoint, did: did, clientProvider: NewOAuthClientProvider(sessionProvider), httpClient: &http.Client{Timeout: 30 * time.Second}, } } // Record represents a generic ATProto record type Record struct { URI string `json:"uri"` CID string `json:"cid"` Value json.RawMessage `json:"value"` } // PutRecord stores a record in the ATProto repository func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record any) (*Record, error) { payload := map[string]any{ "repo": c.did, "collection": collection, "rkey": rkey, "record": record, } var result Record err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error { return client.LexDo(ctx, "POST", "application/json", "com.atproto.repo.putRecord", nil, payload, &result) }) if err != nil { if rl := asRateLimitError(err); rl != nil { return nil, rl } return nil, fmt.Errorf("putRecord failed: %w", err) } return &result, nil } // GetRecord retrieves a record from the ATProto repository func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) { params := map[string]any{ "repo": c.did, "collection": collection, "rkey": rkey, } var result Record err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error { return client.LexDo(ctx, "GET", "", "com.atproto.repo.getRecord", params, nil, &result) }) if err != nil { // Check for xrpc.Error with 404 status code var xrpcErr *xrpc.Error if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == 404 { return nil, ErrRecordNotFound } // Check for RecordNotFound error from indigo's APIError type var apiErr *atclient.APIError if errors.As(err, &apiErr) { if apiErr.StatusCode == 404 || apiErr.Name == "RecordNotFound" { return nil, ErrRecordNotFound } } // Also check error message for RecordNotFound (some error formats) if strings.Contains(err.Error(), "RecordNotFound") { return nil, ErrRecordNotFound } return nil, fmt.Errorf("getRecord failed: %w", err) } return &result, nil } // DeleteRecord deletes a record from the ATProto repository func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) error { payload := map[string]any{ "repo": c.did, "collection": collection, "rkey": rkey, } err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error { var result map[string]any // deleteRecord returns empty object on success return client.LexDo(ctx, "POST", "application/json", "com.atproto.repo.deleteRecord", nil, payload, &result) }) if err != nil { if rl := asRateLimitError(err); rl != nil { return rl } return fmt.Errorf("deleteRecord failed: %w", err) } return nil } // ListRecords lists records in a collection func (c *Client) ListRecords(ctx context.Context, collection string, limit int) ([]Record, error) { params := map[string]any{ "repo": c.did, "collection": collection, "limit": limit, } var result struct { Records []Record `json:"records"` } err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error { return client.LexDo(ctx, "GET", "", "com.atproto.repo.listRecords", params, nil, &result) }) if err != nil { return nil, fmt.Errorf("listRecords failed: %w", err) } return result.Records, nil } // ATProtoBlobRef represents a reference to a blob in ATProto's native blob storage // This is different from OCIBlobDescriptor which describes OCI image layers type ATProtoBlobRef struct { Type string `json:"$type"` Ref Link `json:"ref"` MimeType string `json:"mimeType"` Size int64 `json:"size"` } // Link represents an IPFS link to blob content type Link struct { Link string `json:"$link"` } // UploadBlob uploads binary data to the PDS and returns a blob reference func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*ATProtoBlobRef, error) { var result struct { Blob ATProtoBlobRef `json:"blob"` } // IMPORTANT: Use io.Reader for blob uploads // LexDo JSON-encodes []byte (base64), but streams io.Reader as raw bytes // Use the actual MIME type so PDS can validate against blob:image/* scope err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error { return client.LexDo(ctx, "POST", mimeType, "com.atproto.repo.uploadBlob", nil, bytes.NewReader(data), &result) }) if err != nil { if rl := asRateLimitError(err); rl != nil { return nil, rl } return nil, fmt.Errorf("uploadBlob failed: %w", err) } return &result.Blob, nil } // GetBlob downloads a blob by its CID from the PDS // Note: This is a sync endpoint that returns raw binary data func (c *Client) GetBlob(ctx context.Context, cid string) ([]byte, error) { // Public endpoint - no auth required client := &xrpc.Client{Host: c.pdsEndpoint, Client: c.httpClient} data, err := comatproto.SyncGetBlob(ctx, client, cid, c.did) if err != nil { var xrpcErr *xrpc.Error if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == 404 { return nil, fmt.Errorf("blob not found") } return nil, fmt.Errorf("failed to get blob: %w", err) } return data, nil } // ListReposByCollectionResult represents the response from com.atproto.sync.listReposByCollection type ListReposByCollectionResult struct { Repos []RepoRef `json:"repos"` // Array of repo references Cursor string `json:"cursor,omitempty"` } // RepoRef represents a repository reference in listReposByCollection response type RepoRef struct { DID string `json:"did"` } // ListReposByCollection lists all repos (DIDs) that have records in a collection // This is a network-wide query, not limited to a single PDS (public endpoint) func (c *Client) ListReposByCollection(ctx context.Context, collection string, limit int, cursor string) (*ListReposByCollectionResult, error) { params := map[string]any{"collection": collection} if limit > 0 { params["limit"] = limit } if cursor != "" { params["cursor"] = cursor } // Public endpoint - no auth required client := &xrpc.Client{Host: c.pdsEndpoint, Client: c.httpClient} var result ListReposByCollectionResult err := client.LexDo(ctx, "GET", "", "com.atproto.sync.listReposByCollection", params, nil, &result) if err != nil { return nil, fmt.Errorf("listReposByCollection failed: %w", err) } return &result, nil } // ListRecordsForRepo lists records in a collection for a specific repo (DID) // This differs from ListRecords which uses the client's DID (public endpoint) func (c *Client) ListRecordsForRepo(ctx context.Context, repoDID, collection string, limit int, cursor string) ([]Record, string, error) { params := map[string]any{ "repo": repoDID, "collection": collection, } if limit > 0 { params["limit"] = limit } if cursor != "" { params["cursor"] = cursor } // Public endpoint - no auth required client := &xrpc.Client{Host: c.pdsEndpoint, Client: c.httpClient} var result struct { Records []Record `json:"records"` Cursor string `json:"cursor,omitempty"` } err := client.LexDo(ctx, "GET", "", "com.atproto.repo.listRecords", params, nil, &result) if err != nil { return nil, "", fmt.Errorf("listRecords failed: %w", err) } return result.Records, result.Cursor, nil } // ActorProfile represents a Bluesky actor profile (from AppView) type ActorProfile struct { DID string `json:"did"` Handle string `json:"handle"` DisplayName string `json:"displayName,omitempty"` Description string `json:"description,omitempty"` Avatar string `json:"avatar,omitempty"` // CDN URL from AppView } // ProfileRecord represents the app.bsky.actor.profile record (from PDS) type ProfileRecord struct { DisplayName string `json:"displayName,omitempty"` Description string `json:"description,omitempty"` Avatar *ATProtoBlobRef `json:"avatar,omitempty"` // Blob reference Banner *ATProtoBlobRef `json:"banner,omitempty"` CreatedAt string `json:"createdAt,omitempty"` } // GetActorProfile fetches an actor's profile from their PDS // The actor parameter can be a DID or handle func (c *Client) GetActorProfile(ctx context.Context, actor string) (*ActorProfile, error) { // Public endpoint - doesn't require auth client := &xrpc.Client{Host: c.pdsEndpoint, Client: c.httpClient} resp, err := appbsky.ActorGetProfile(ctx, client, actor) if err != nil { return nil, fmt.Errorf("failed to get profile: %w", err) } // Convert indigo's type to our ActorProfile profile := &ActorProfile{ DID: resp.Did, Handle: resp.Handle, } if resp.DisplayName != nil { profile.DisplayName = *resp.DisplayName } if resp.Description != nil { profile.Description = *resp.Description } if resp.Avatar != nil { profile.Avatar = *resp.Avatar } return profile, nil } // GetProfileRecord fetches the app.bsky.actor.profile record from PDS // This returns the raw profile record with blob references (not CDN URLs) func (c *Client) GetProfileRecord(ctx context.Context, did string) (*ProfileRecord, error) { params := map[string]any{ "repo": did, "collection": "app.bsky.actor.profile", "rkey": "self", } var result struct { Value ProfileRecord `json:"value"` } err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error { return client.LexDo(ctx, "GET", "", "com.atproto.repo.getRecord", params, nil, &result) }) if err != nil { return nil, fmt.Errorf("getRecord failed: %w", err) } return &result.Value, nil } // BlobCDNURL constructs an imgs.blue CDN URL for a blob // The imgs.blue service can serve blobs using DID or handle func BlobCDNURL(didOrHandle, cid string) string { return fmt.Sprintf("https://imgs.blue/%s/%s", didOrHandle, cid) } // DIDDocument represents a did:web document type DIDDocument struct { Context []string `json:"@context"` ID string `json:"id"` Service []struct { ID string `json:"id"` Type string `json:"type"` ServiceEndpoint string `json:"serviceEndpoint"` } `json:"service"` } // FetchDIDDocument fetches and parses a DID document from a URL func (c *Client) FetchDIDDocument(ctx context.Context, didDocURL string) (*DIDDocument, error) { req, err := http.NewRequestWithContext(ctx, "GET", didDocURL, nil) if err != nil { return nil, err } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch DID document: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch DID document failed with status %d", resp.StatusCode) } var didDoc DIDDocument if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil { return nil, fmt.Errorf("failed to decode DID document: %w", err) } return &didDoc, nil } // DID returns the DID associated with this client func (c *Client) DID() string { return c.did } func (c *Client) PDSEndpoint() string { return c.pdsEndpoint } // ListRecordsWithCursor lists records in a collection with cursor-based pagination. // Returns records, next cursor (empty if no more), and error. func (c *Client) ListRecordsWithCursor(ctx context.Context, collection string, limit int, cursor string) ([]Record, string, error) { params := map[string]any{ "repo": c.did, "collection": collection, "limit": limit, } if cursor != "" { params["cursor"] = cursor } var result struct { Records []Record `json:"records"` Cursor string `json:"cursor,omitempty"` } err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error { return client.LexDo(ctx, "GET", "", "com.atproto.repo.listRecords", params, nil, &result) }) if err != nil { return nil, "", fmt.Errorf("listRecords failed: %w", err) } return result.Records, result.Cursor, nil } // DeleteAllRecordsInCollection deletes all records in a collection. // Returns the number of records deleted. // This is used for GDPR account deletion to remove all user records from a collection. func (c *Client) DeleteAllRecordsInCollection(ctx context.Context, collection string) (int, error) { deleted := 0 cursor := "" for { // List records with pagination records, nextCursor, err := c.ListRecordsWithCursor(ctx, collection, 100, cursor) if err != nil { return deleted, fmt.Errorf("failed to list records: %w", err) } for _, rec := range records { // Extract rkey from URI (at://{did}/{collection}/{rkey}) rkey := extractRkeyFromURI(rec.URI) if rkey == "" { continue } err := c.DeleteRecord(ctx, collection, rkey) if err != nil { // Log but continue with other records continue } deleted++ } if nextCursor == "" { break } cursor = nextCursor } return deleted, nil } // extractRkeyFromURI extracts the rkey from an AT URI (at://{did}/{collection}/{rkey}) func extractRkeyFromURI(uri string) string { // Format: at://did:plc:xxx/io.atcr.manifest/abc123 parts := strings.Split(uri, "/") if len(parts) < 5 { return "" } return parts[len(parts)-1] }