···11+package netclient
22+33+import (
44+ "bytes"
55+ "context"
66+ "encoding/json"
77+ "errors"
88+ "fmt"
99+ "io"
1010+ "log/slog"
1111+ "net/http"
1212+1313+ "github.com/bluesky-social/indigo/atproto/identity"
1414+ "github.com/bluesky-social/indigo/atproto/syntax"
1515+)
1616+1717+type NetClient struct {
1818+ Client *http.Client
1919+ // NOTE: maybe should use a "resolver" which doesn't do handle resolution? or leave that to calling code to configure
2020+ Dir identity.Directory
2121+ UserAgent string
2222+}
2323+2424+func NewNetClient() *NetClient {
2525+ return &NetClient{
2626+ // TODO: maybe custom client: SSRF, retries, timeout
2727+ Client: http.DefaultClient,
2828+ Dir: identity.DefaultDirectory(),
2929+ UserAgent: "cobalt-netclient",
3030+ }
3131+}
3232+3333+// Fetches repo export (CAR file). Calling code is responsible for closing the returned [io.ReadCloser] on success (often an HTTP response body). Does not verify signatures or CAR format or structure in any way.
3434+func (nc *NetClient) GetRepoCAR(ctx context.Context, did syntax.DID) (io.ReadCloser, error) {
3535+ ident, err := nc.Dir.LookupDID(ctx, did)
3636+ if err != nil {
3737+ return nil, err
3838+ }
3939+ host := ident.PDSEndpoint()
4040+ if host == "" {
4141+ return nil, fmt.Errorf("account has no PDS host registered: %s", did.String())
4242+ }
4343+ // TODO: validate host
4444+ // TODO: DID escaping (?)
4545+ u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepo?did=%s", host, did)
4646+4747+ slog.Debug("downloading repo CAR", "did", did, "url", u)
4848+ req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
4949+ if err != nil {
5050+ return nil, err
5151+ }
5252+ if nc.UserAgent != "" {
5353+ req.Header.Set("User-Agent", nc.UserAgent)
5454+ }
5555+ req.Header.Set("Accept", "application/vnd.ipld.car")
5656+5757+ resp, err := nc.Client.Do(req)
5858+ if err != nil {
5959+ return nil, fmt.Errorf("fetching repo CAR file (%s): %w", did, err)
6060+ }
6161+6262+ if resp.StatusCode != http.StatusOK {
6363+ resp.Body.Close()
6464+ return nil, fmt.Errorf("HTTP error fetching repo CAR file (%s): %d", did, resp.StatusCode)
6565+ }
6666+6767+ return resp.Body, nil
6868+}
6969+7070+// Resolves and fetches blob from the network. Calling code must close the returned [io.ReadCloser] (eg, HTTP response body). Does not verify CID.
7171+func (nc *NetClient) GetBlobReader(ctx context.Context, did syntax.DID, cid syntax.CID) (io.ReadCloser, error) {
7272+ ident, err := nc.Dir.LookupDID(ctx, did)
7373+ if err != nil {
7474+ return nil, err
7575+ }
7676+ host := ident.PDSEndpoint()
7777+ if host == "" {
7878+ return nil, fmt.Errorf("account has no PDS host registered: %s", did.String())
7979+ }
8080+ // TODO: validate host
8181+ // TODO: DID escaping (?)
8282+ u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", host, did, cid)
8383+8484+ slog.Debug("downloading blob", "did", did, "cid", cid, "url", u)
8585+ req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
8686+ if err != nil {
8787+ return nil, err
8888+ }
8989+ if nc.UserAgent != "" {
9090+ req.Header.Set("User-Agent", nc.UserAgent)
9191+ }
9292+ req.Header.Set("Accept", "*/*")
9393+9494+ resp, err := nc.Client.Do(req)
9595+ if err != nil {
9696+ return nil, fmt.Errorf("fetching blob (%s, %s): %w", did, cid, err)
9797+ }
9898+9999+ if resp.StatusCode != http.StatusOK {
100100+ resp.Body.Close()
101101+ return nil, fmt.Errorf("HTTP error fetching blob (%s, %s): %d", did, cid, resp.StatusCode)
102102+ }
103103+104104+ return resp.Body, nil
105105+}
106106+107107+var ErrMismatchedBlobCID = errors.New("mismatched blob CID")
108108+109109+// Fetches blob, writes in to provided buffer, and verified CID hash.
110110+func (nc *NetClient) GetBlob(ctx context.Context, did syntax.DID, cid syntax.CID, buf *bytes.Buffer) error {
111111+ stream, err := nc.GetBlobReader(ctx, did, cid)
112112+ if err != nil {
113113+ return err
114114+ }
115115+ defer stream.Close()
116116+117117+ if _, err := io.Copy(buf, stream); err != nil {
118118+ return err
119119+ }
120120+121121+ c, err := computeCID(buf.Bytes())
122122+ if err != nil {
123123+ return err
124124+ }
125125+126126+ if c.String() != cid.String() {
127127+ return ErrMismatchedBlobCID
128128+ }
129129+ return nil
130130+}
131131+132132+type repoStatusResp struct {
133133+ Active bool `json:"active"`
134134+ DID string `json:"did"`
135135+ Status string `json:"status,omitempty"`
136136+}
137137+138138+// Fetches account status. Returns a boolean indicating active state, and a string describing any non-active status.
139139+func (nc *NetClient) GetAccountStatus(ctx context.Context, did syntax.DID) (active bool, status string, err error) {
140140+ ident, err := nc.Dir.LookupDID(ctx, did)
141141+ if err != nil {
142142+ return false, "", err
143143+ }
144144+ host := ident.PDSEndpoint()
145145+ if host == "" {
146146+ return false, "", fmt.Errorf("account has no PDS host registered: %s", did.String())
147147+ }
148148+ // TODO: validate host
149149+ // TODO: DID escaping (?)
150150+ u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepoStatus?did=%s", host, did)
151151+152152+ slog.Debug("fetching account status", "did", did, "url", u)
153153+ req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
154154+ if err != nil {
155155+ return false, "", err
156156+ }
157157+ if nc.UserAgent != "" {
158158+ req.Header.Set("User-Agent", nc.UserAgent)
159159+ }
160160+ req.Header.Set("Accept", "application/json")
161161+162162+ resp, err := nc.Client.Do(req)
163163+ if err != nil {
164164+ return false, "", fmt.Errorf("fetching account status (%s): %w", did, err)
165165+ }
166166+ defer resp.Body.Close()
167167+168168+ if resp.StatusCode != http.StatusOK {
169169+ return false, "", fmt.Errorf("HTTP error fetching account status (%s): %d", did, resp.StatusCode)
170170+ }
171171+172172+ var rsr repoStatusResp
173173+ if err := json.NewDecoder(resp.Body).Decode(&rsr); err != nil {
174174+ return false, "", fmt.Errorf("failed decoding account status response: %w", err)
175175+ }
176176+177177+ return rsr.Active, rsr.Status, nil
178178+}
-39
atproto/netclient/network_client.go
···11-package netclient
22-33-import (
44- "context"
55- "encoding/json"
66- "errors"
77- "io"
88-99- "github.com/bluesky-social/indigo/atproto/syntax"
1010-)
1111-1212-// API for clients which pull data from the public atproto network.
1313-//
1414-// Implementations of this interface might resolve PDS instances for DIDs, and fetch data from there. Or they might talk to an archival relay or other network mirroring service.
1515-type NetworkClient interface {
1616- // Fetches record JSON, without verification or validation. A version (CID) can optionally be specified; use empty string to fetch the latest.
1717- // Returns the record as JSON, and the CID indicated by the server. Does not verify that the data (as CBOR) matches the CID, and does not cryptographically verify a "proof chain" to the record.
1818- GetRecordJSON(ctx context.Context, aturi syntax.ATURI, version syntax.CID) (*json.RawMessage, *syntax.CID, error)
1919-2020- // Fetches the indicated record as CBOR, and authenticates it by checking both the cryptographic signature and Merkle Tree hashes from the current repo revision. A version (CID) can optionally be specified; use empty string to fetch the latest.
2121- // Returns the record as CBOR; the CID of the validated record, and the repo commit revision.
2222- VerifyRecordCBOR(ctx context.Context, aturi syntax.ATURI, version syntax.CID) (*[]byte, *syntax.CID, string, error)
2323-2424- // Fetches repo export (CAR file). Optionally attempts to fetch only the diff "since" an earlier repo revision.
2525- GetRepoCAR(ctx context.Context, did syntax.DID, since string) (*io.Reader, error)
2626-2727- // Fetches indicated blob. Does not validate the CID. Returns a reader (which calling code is responsible for closing).
2828- GetBlob(ctx context.Context, did syntax.DID, cid syntax.CID) (*io.ReadCloser, error)
2929- GetAccountStatus(ctx context.Context, did syntax.DID) (*AccountStatus, error)
3030-}
3131-3232-// XXX: type alias to codegen? or just copy? this is protocol-level
3333-type AccountStatus struct {
3434-}
3535-3636-func VerifyBlobCID(blob []byte, cid syntax.CID) error {
3737- // XXX: compute hash, check against provided CID
3838- return errors.New("Not Implemented")
3939-}