···1+package netclient
2+3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "log/slog"
11+ "net/http"
12+13+ "github.com/bluesky-social/indigo/atproto/identity"
14+ "github.com/bluesky-social/indigo/atproto/syntax"
15+)
16+17+type NetClient struct {
18+ Client *http.Client
19+ // NOTE: maybe should use a "resolver" which doesn't do handle resolution? or leave that to calling code to configure
20+ Dir identity.Directory
21+ UserAgent string
22+}
23+24+func NewNetClient() *NetClient {
25+ return &NetClient{
26+ // TODO: maybe custom client: SSRF, retries, timeout
27+ Client: http.DefaultClient,
28+ Dir: identity.DefaultDirectory(),
29+ UserAgent: "cobalt-netclient",
30+ }
31+}
32+33+// 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.
34+func (nc *NetClient) GetRepoCAR(ctx context.Context, did syntax.DID) (io.ReadCloser, error) {
35+ ident, err := nc.Dir.LookupDID(ctx, did)
36+ if err != nil {
37+ return nil, err
38+ }
39+ host := ident.PDSEndpoint()
40+ if host == "" {
41+ return nil, fmt.Errorf("account has no PDS host registered: %s", did.String())
42+ }
43+ // TODO: validate host
44+ // TODO: DID escaping (?)
45+ u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepo?did=%s", host, did)
46+47+ slog.Debug("downloading repo CAR", "did", did, "url", u)
48+ req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
49+ if err != nil {
50+ return nil, err
51+ }
52+ if nc.UserAgent != "" {
53+ req.Header.Set("User-Agent", nc.UserAgent)
54+ }
55+ req.Header.Set("Accept", "application/vnd.ipld.car")
56+57+ resp, err := nc.Client.Do(req)
58+ if err != nil {
59+ return nil, fmt.Errorf("fetching repo CAR file (%s): %w", did, err)
60+ }
61+62+ if resp.StatusCode != http.StatusOK {
63+ resp.Body.Close()
64+ return nil, fmt.Errorf("HTTP error fetching repo CAR file (%s): %d", did, resp.StatusCode)
65+ }
66+67+ return resp.Body, nil
68+}
69+70+// Resolves and fetches blob from the network. Calling code must close the returned [io.ReadCloser] (eg, HTTP response body). Does not verify CID.
71+func (nc *NetClient) GetBlobReader(ctx context.Context, did syntax.DID, cid syntax.CID) (io.ReadCloser, error) {
72+ ident, err := nc.Dir.LookupDID(ctx, did)
73+ if err != nil {
74+ return nil, err
75+ }
76+ host := ident.PDSEndpoint()
77+ if host == "" {
78+ return nil, fmt.Errorf("account has no PDS host registered: %s", did.String())
79+ }
80+ // TODO: validate host
81+ // TODO: DID escaping (?)
82+ u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", host, did, cid)
83+84+ slog.Debug("downloading blob", "did", did, "cid", cid, "url", u)
85+ req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
86+ if err != nil {
87+ return nil, err
88+ }
89+ if nc.UserAgent != "" {
90+ req.Header.Set("User-Agent", nc.UserAgent)
91+ }
92+ req.Header.Set("Accept", "*/*")
93+94+ resp, err := nc.Client.Do(req)
95+ if err != nil {
96+ return nil, fmt.Errorf("fetching blob (%s, %s): %w", did, cid, err)
97+ }
98+99+ if resp.StatusCode != http.StatusOK {
100+ resp.Body.Close()
101+ return nil, fmt.Errorf("HTTP error fetching blob (%s, %s): %d", did, cid, resp.StatusCode)
102+ }
103+104+ return resp.Body, nil
105+}
106+107+var ErrMismatchedBlobCID = errors.New("mismatched blob CID")
108+109+// Fetches blob, writes in to provided buffer, and verified CID hash.
110+func (nc *NetClient) GetBlob(ctx context.Context, did syntax.DID, cid syntax.CID, buf *bytes.Buffer) error {
111+ stream, err := nc.GetBlobReader(ctx, did, cid)
112+ if err != nil {
113+ return err
114+ }
115+ defer stream.Close()
116+117+ if _, err := io.Copy(buf, stream); err != nil {
118+ return err
119+ }
120+121+ c, err := computeCID(buf.Bytes())
122+ if err != nil {
123+ return err
124+ }
125+126+ if c.String() != cid.String() {
127+ return ErrMismatchedBlobCID
128+ }
129+ return nil
130+}
131+132+type repoStatusResp struct {
133+ Active bool `json:"active"`
134+ DID string `json:"did"`
135+ Status string `json:"status,omitempty"`
136+}
137+138+// Fetches account status. Returns a boolean indicating active state, and a string describing any non-active status.
139+func (nc *NetClient) GetAccountStatus(ctx context.Context, did syntax.DID) (active bool, status string, err error) {
140+ ident, err := nc.Dir.LookupDID(ctx, did)
141+ if err != nil {
142+ return false, "", err
143+ }
144+ host := ident.PDSEndpoint()
145+ if host == "" {
146+ return false, "", fmt.Errorf("account has no PDS host registered: %s", did.String())
147+ }
148+ // TODO: validate host
149+ // TODO: DID escaping (?)
150+ u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepoStatus?did=%s", host, did)
151+152+ slog.Debug("fetching account status", "did", did, "url", u)
153+ req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
154+ if err != nil {
155+ return false, "", err
156+ }
157+ if nc.UserAgent != "" {
158+ req.Header.Set("User-Agent", nc.UserAgent)
159+ }
160+ req.Header.Set("Accept", "application/json")
161+162+ resp, err := nc.Client.Do(req)
163+ if err != nil {
164+ return false, "", fmt.Errorf("fetching account status (%s): %w", did, err)
165+ }
166+ defer resp.Body.Close()
167+168+ if resp.StatusCode != http.StatusOK {
169+ return false, "", fmt.Errorf("HTTP error fetching account status (%s): %d", did, resp.StatusCode)
170+ }
171+172+ var rsr repoStatusResp
173+ if err := json.NewDecoder(resp.Body).Decode(&rsr); err != nil {
174+ return false, "", fmt.Errorf("failed decoding account status response: %w", err)
175+ }
176+177+ return rsr.Active, rsr.Status, nil
178+}
-39
atproto/netclient/network_client.go
···1-package netclient
2-3-import (
4- "context"
5- "encoding/json"
6- "errors"
7- "io"
8-9- "github.com/bluesky-social/indigo/atproto/syntax"
10-)
11-12-// API for clients which pull data from the public atproto network.
13-//
14-// 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.
15-type NetworkClient interface {
16- // Fetches record JSON, without verification or validation. A version (CID) can optionally be specified; use empty string to fetch the latest.
17- // 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.
18- GetRecordJSON(ctx context.Context, aturi syntax.ATURI, version syntax.CID) (*json.RawMessage, *syntax.CID, error)
19-20- // 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.
21- // Returns the record as CBOR; the CID of the validated record, and the repo commit revision.
22- VerifyRecordCBOR(ctx context.Context, aturi syntax.ATURI, version syntax.CID) (*[]byte, *syntax.CID, string, error)
23-24- // Fetches repo export (CAR file). Optionally attempts to fetch only the diff "since" an earlier repo revision.
25- GetRepoCAR(ctx context.Context, did syntax.DID, since string) (*io.Reader, error)
26-27- // Fetches indicated blob. Does not validate the CID. Returns a reader (which calling code is responsible for closing).
28- GetBlob(ctx context.Context, did syntax.DID, cid syntax.CID) (*io.ReadCloser, error)
29- GetAccountStatus(ctx context.Context, did syntax.DID) (*AccountStatus, error)
30-}
31-32-// XXX: type alias to codegen? or just copy? this is protocol-level
33-type AccountStatus struct {
34-}
35-36-func VerifyBlobCID(blob []byte, cid syntax.CID) error {
37- // XXX: compute hash, check against provided CID
38- return errors.New("Not Implemented")
39-}