package atproto import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "github.com/bluesky-social/indigo/atproto/atclient" ) // Sentinel errors var ( ErrRecordNotFound = errors.New("record not found") ) // Client wraps ATProto operations for the registry type Client struct { pdsEndpoint string did string accessToken string // For Basic Auth only httpClient *http.Client useIndigoClient bool // true if using indigo's OAuth client (handles auth automatically) indigoClient *atclient.APIClient // indigo's API client for OAuth requests } // 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, accessToken: accessToken, httpClient: &http.Client{}, } } // NewClientWithIndigoClient creates an ATProto client using indigo's API client // This uses indigo's native XRPC methods with automatic DPoP handling func NewClientWithIndigoClient(pdsEndpoint, did string, indigoClient *atclient.APIClient) *Client { return &Client{ pdsEndpoint: pdsEndpoint, did: did, useIndigoClient: true, indigoClient: indigoClient, httpClient: indigoClient.Client, // Keep for any fallback cases } } // 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, } // Use indigo API client (OAuth with DPoP) if c.useIndigoClient && c.indigoClient != nil { var result Record err := c.indigoClient.Post(ctx, "com.atproto.repo.putRecord", payload, &result) if err != nil { return nil, fmt.Errorf("putRecord failed: %w", err) } return &result, nil } // Basic Auth (app passwords) body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("failed to marshal record: %w", err) } url := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", c.pdsEndpoint) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.accessToken) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to put record: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("put record failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var result Record if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode response: %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) { // Use indigo API client (OAuth with DPoP) if c.useIndigoClient && c.indigoClient != nil { params := map[string]any{ "repo": c.did, "collection": collection, "rkey": rkey, } var result Record err := c.indigoClient.Get(ctx, "com.atproto.repo.getRecord", params, &result) if err != nil { // 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 } } return nil, fmt.Errorf("getRecord failed: %w", err) } return &result, nil } // Basic Auth (app passwords) url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", c.pdsEndpoint, c.did, collection, rkey) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } // Only set Authorization header if we have a token // Empty Bearer tokens will be rejected by PDS if c.accessToken != "" { req.Header.Set("Authorization", "Bearer "+c.accessToken) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to get record: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, ErrRecordNotFound } if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) bodyStr := string(bodyBytes) // Check for RecordNotFound error (PDS returns 400 with this error) if strings.Contains(bodyStr, "RecordNotFound") { return nil, ErrRecordNotFound } return nil, fmt.Errorf("get record failed with status %d: %s", resp.StatusCode, bodyStr) } var result Record if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode response: %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, } // Use indigo API client (OAuth with DPoP) if c.useIndigoClient && c.indigoClient != nil { var result map[string]any // deleteRecord returns empty object on success err := c.indigoClient.Post(ctx, "com.atproto.repo.deleteRecord", payload, &result) if err != nil { return fmt.Errorf("deleteRecord failed: %w", err) } return nil } // Basic Auth (app passwords) body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal delete request: %w", err) } url := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", c.pdsEndpoint) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.accessToken) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to delete record: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("delete record failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } return nil } // ListRecords lists records in a collection func (c *Client) ListRecords(ctx context.Context, collection string, limit int) ([]Record, error) { url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=%d", c.pdsEndpoint, c.did, collection, limit) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } // Only set Authorization header if we have a token // Empty Bearer tokens will be rejected by PDS if c.accessToken != "" { req.Header.Set("Authorization", "Bearer "+c.accessToken) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to list records: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var result struct { Records []Record `json:"records"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode response: %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) { // Use indigo API client (OAuth with DPoP) if c.useIndigoClient && c.indigoClient != nil { var result struct { Blob ATProtoBlobRef `json:"blob"` } err := c.indigoClient.LexDo(ctx, "POST", mimeType, "com.atproto.repo.uploadBlob", nil, data, &result, ) if err != nil { return nil, fmt.Errorf("uploadBlob failed: %w", err) } return &result.Blob, nil } // Basic Auth (app passwords) url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", c.pdsEndpoint) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.accessToken) req.Header.Set("Content-Type", mimeType) resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to upload blob: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("upload blob failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var result struct { Blob ATProtoBlobRef `json:"blob"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } return &result.Blob, nil } // GetBlob downloads a blob by its CID from the PDS func (c *Client) GetBlob(ctx context.Context, cid string) ([]byte, error) { url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", c.pdsEndpoint, c.did, cid) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } // Note: getBlob may not require auth for public repos, but we include it anyway if c.accessToken != "" { req.Header.Set("Authorization", "Bearer "+c.accessToken) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to get blob: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("blob not found") } if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("get blob failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } // Read the blob data data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read blob data: %w", err) } // Check if PDS returned JSON-wrapped blob (Bluesky implementation) // PDS may wrap blobs as JSON-encoded base64 strings // Detection: Check if content starts with a quote (indicating JSON string) if len(data) > 0 && data[0] == '"' { // Blob is JSON-encoded - decode it var base64Str string if err := json.Unmarshal(data, &base64Str); err != nil { return nil, fmt.Errorf("failed to unmarshal JSON-wrapped blob: %w", err) } // Base64-decode the blob content decoded, err := base64.StdEncoding.DecodeString(base64Str) if err != nil { return nil, fmt.Errorf("failed to base64-decode blob: %w", err) } return decoded, nil } // Raw blob response (expected ATProto behavior) 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 func (c *Client) ListReposByCollection(ctx context.Context, collection string, limit int, cursor string) (*ListReposByCollectionResult, error) { // Build URL with query parameters url := fmt.Sprintf("%s/xrpc/com.atproto.sync.listReposByCollection?collection=%s", c.pdsEndpoint, collection) if limit > 0 { url += fmt.Sprintf("&limit=%d", limit) } if cursor != "" { url += fmt.Sprintf("&cursor=%s", cursor) } req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } // This endpoint typically doesn't require auth for public data // but we include it if available if c.accessToken != "" { req.Header.Set("Authorization", "Bearer "+c.accessToken) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to list repos by collection: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("list repos by collection failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var result ListReposByCollectionResult if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode response: %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 func (c *Client) ListRecordsForRepo(ctx context.Context, repoDID, collection string, limit int, cursor string) ([]Record, string, error) { url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", c.pdsEndpoint, repoDID, collection) if limit > 0 { url += fmt.Sprintf("&limit=%d", limit) } if cursor != "" { url += fmt.Sprintf("&cursor=%s", cursor) } req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, "", err } // This endpoint typically doesn't require auth for public records if c.accessToken != "" { req.Header.Set("Authorization", "Bearer "+c.accessToken) } resp, err := c.httpClient.Do(req) if err != nil { return nil, "", fmt.Errorf("failed to list records: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, "", fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var result struct { Records []Record `json:"records"` Cursor string `json:"cursor,omitempty"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, "", fmt.Errorf("failed to decode response: %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) { // Use indigo API client (OAuth with DPoP) if c.useIndigoClient && c.indigoClient != nil { params := map[string]any{ "actor": actor, } var profile ActorProfile err := c.indigoClient.Get(ctx, "app.bsky.actor.getProfile", params, &profile) if err != nil { return nil, fmt.Errorf("getProfile failed: %w", err) } return &profile, nil } // Basic Auth (app passwords) url := fmt.Sprintf("%s/xrpc/app.bsky.actor.getProfile?actor=%s", c.pdsEndpoint, actor) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } // This endpoint typically doesn't require auth for public profiles if c.accessToken != "" { req.Header.Set("Authorization", "Bearer "+c.accessToken) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to get profile: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("profile not found") } if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("get profile failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var profile ActorProfile if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { return nil, fmt.Errorf("failed to decode profile: %w", err) } 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) { // Use indigo API client (OAuth with DPoP) if c.useIndigoClient && c.indigoClient != nil { params := map[string]any{ "repo": did, "collection": "app.bsky.actor.profile", "rkey": "self", } var result struct { Value ProfileRecord `json:"value"` } err := c.indigoClient.Get(ctx, "com.atproto.repo.getRecord", params, &result) if err != nil { return nil, fmt.Errorf("getRecord failed: %w", err) } return &result.Value, nil } // Basic Auth (app passwords) url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=app.bsky.actor.profile&rkey=self", c.pdsEndpoint, did) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } if c.accessToken != "" { req.Header.Set("Authorization", "Bearer "+c.accessToken) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to get profile record: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("profile record not found") } if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("get profile record failed with status %d: %s", resp.StatusCode, string(bodyBytes)) } var result struct { Value ProfileRecord `json:"value"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode profile record: %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 }