package atp import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" ) // Maximum PDS blob size (1 MB). const MaxBlobSize = 1024 * 1024 // Client wraps an authenticated atclient.APIClient for PDS CRUD operations. // Create one per-user session via `ResumeSession` or [NewClient]. type Client struct { api *atclient.APIClient did syntax.DID } // NewClient wraps an existing indigo APIClient. Use this when you manage // OAuth sessions yourself (e.g. CLI apps that call ResumeSession directly). func NewClient(api *atclient.APIClient, did syntax.DID) *Client { return &Client{api: api, did: did} } func (c *Client) DID() syntax.DID { return c.did } // APIClient returns the underlying indigo APIClient for advanced usage // (custom XRPC calls, service proxying, etc.). func (c *Client) APIClient() *atclient.APIClient { return c.api } func (c *Client) CreateRecord(ctx context.Context, collection string, record any) (uri, cid string, err error) { body := map[string]any{ "repo": c.did.String(), "collection": collection, "record": record, } var result struct { URI string `json:"uri"` CID string `json:"cid"` } if err := c.api.Post(ctx, "com.atproto.repo.createRecord", body, &result); err != nil { return "", "", WrapPDSError(fmt.Errorf("create record in %s: %w", collection, err)) } return result.URI, result.CID, nil } func (c *Client) CreateRecordWithRKey(ctx context.Context, collection, rkey string, record any) (uri, cid string, err error) { body := map[string]any{ "repo": c.did.String(), "collection": collection, "rkey": rkey, "record": record, } var result struct { URI string `json:"uri"` CID string `json:"cid"` } if err := c.api.Post(ctx, "com.atproto.repo.createRecord", body, &result); err != nil { return "", "", WrapPDSError(fmt.Errorf("create record %s/%s: %w", collection, rkey, err)) } return result.URI, result.CID, nil } // GetRecord retrieves a single record by collection and record key. func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) { params := map[string]any{ "repo": c.did.String(), "collection": collection, "rkey": rkey, } var result struct { URI string `json:"uri"` CID string `json:"cid"` Value map[string]any `json:"value"` } if err := c.api.Get(ctx, "com.atproto.repo.getRecord", params, &result); err != nil { return nil, WrapPDSError(fmt.Errorf("get record %s/%s: %w", collection, rkey, err)) } return &Record{URI: result.URI, CID: result.CID, Value: result.Value}, nil } // Pass limit <= 0 for the server default (usually 50). // Pass empty cursor for the first page. func (c *Client) ListRecords(ctx context.Context, collection string, limit int, cursor string) (*ListResult, error) { params := map[string]any{ "repo": c.did.String(), "collection": collection, } if limit > 0 { params["limit"] = limit } if cursor != "" { params["cursor"] = cursor } var result struct { Records []struct { URI string `json:"uri"` CID string `json:"cid"` Value map[string]any `json:"value"` } `json:"records"` Cursor *string `json:"cursor,omitempty"` } if err := c.api.Get(ctx, "com.atproto.repo.listRecords", params, &result); err != nil { return nil, WrapPDSError(fmt.Errorf("list records %s: %w", collection, err)) } records := make([]Record, len(result.Records)) for i, r := range result.Records { records[i] = Record{URI: r.URI, CID: r.CID, Value: r.Value} } out := &ListResult{Records: records} if result.Cursor != nil && *result.Cursor != "" { out.Cursor = *result.Cursor } return out, nil } func (c *Client) ListAllRecords(ctx context.Context, collection string) ([]Record, error) { var all []Record cursor := "" for { select { case <-ctx.Done(): return nil, ctx.Err() default: } page, err := c.ListRecords(ctx, collection, 100, cursor) if err != nil { return nil, err } all = append(all, page.Records...) if page.Cursor == "" { break } cursor = page.Cursor } return all, nil } func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record any) (uri, cid string, err error) { body := map[string]any{ "repo": c.did.String(), "collection": collection, "rkey": rkey, "record": record, } var result struct { URI string `json:"uri"` CID string `json:"cid"` } if err := c.api.Post(ctx, "com.atproto.repo.putRecord", body, &result); err != nil { return "", "", WrapPDSError(fmt.Errorf("put record %s/%s: %w", collection, rkey, err)) } return result.URI, result.CID, nil } func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) error { body := map[string]any{ "repo": c.did.String(), "collection": collection, "rkey": rkey, } var result struct{} if err := c.api.Post(ctx, "com.atproto.repo.deleteRecord", body, &result); err != nil { return WrapPDSError(fmt.Errorf("delete record %s/%s: %w", collection, rkey, err)) } return nil } // Data must be at most 1 MB (MaxBlobSize). The mimeType should match the blob content. func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*BlobRef, error) { if len(data) > MaxBlobSize { return nil, fmt.Errorf("blob size %d exceeds maximum %d bytes", len(data), MaxBlobSize) } endpoint, err := syntax.ParseNSID("com.atproto.repo.uploadBlob") if err != nil { return nil, err } reader := bytes.NewReader(data) req := atclient.NewAPIRequest(http.MethodPost, endpoint, reader) req.Headers = http.Header{} req.Headers.Set("Content-Type", mimeType) req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil } resp, err := c.api.Do(ctx, req) if err != nil { return nil, WrapPDSError(fmt.Errorf("upload blob: %w", err)) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read upload response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, WrapPDSError(fmt.Errorf("upload blob: HTTP %d: %s", resp.StatusCode, string(respBody))) } var output atproto.RepoUploadBlob_Output if err := json.Unmarshal(respBody, &output); err != nil { return nil, fmt.Errorf("unmarshal upload response: %w", err) } if output.Blob == nil { return nil, fmt.Errorf("upload blob: missing blob in response") } return &BlobRef{ Type: "blob", MimeType: output.Blob.MimeType, Size: int(output.Blob.Size), Ref: CIDLink{Link: output.Blob.Ref.String()}, }, nil } func (c *Client) GetBlob(ctx context.Context, cid string) ([]byte, error) { data, err := atproto.SyncGetBlob(ctx, c.api, cid, c.did.String()) if err != nil { return nil, WrapPDSError(fmt.Errorf("get blob %s: %w", cid, err)) } return data, nil }