Files
at-container-registry/pkg/atproto/client.go
2026-02-09 22:39:38 -06:00

524 lines
17 KiB
Go

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")
)
// 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 {
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 {
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 {
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]
}