Files
at-container-registry/pkg/atproto/client.go
T
2025-10-08 13:24:39 -05:00

584 lines
17 KiB
Go

package atproto
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/bluesky-social/indigo/atproto/client"
)
// 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 *client.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 *client.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 {
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") {
return nil, fmt.Errorf("record not found")
}
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
}
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, fmt.Errorf("record not found")
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get 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
}
// 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
}
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)
}
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)
}