diff --git a/atproto/client/admin_auth.go b/atproto/client/admin_auth.go new file mode 100644 index 000000000..d40438c15 --- /dev/null +++ b/atproto/client/admin_auth.go @@ -0,0 +1,28 @@ +package client + +import ( + "context" + "encoding/base64" + "net/http" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +type AdminAuth struct { + basicAuthHeader string +} + +func NewAdminAuth(password string) AdminAuth { + header := "Basic" + base64.StdEncoding.EncodeToString([]byte("admin:"+password)) + return AdminAuth{basicAuthHeader: header} +} + +func (a *AdminAuth) DoWithAuth(ctx context.Context, req *http.Request, httpClient *http.Client) (*http.Response, error) { + req.Header.Set("Authorization", a.basicAuthHeader) + return httpClient.Do(req) +} + +// Admin bearer token auth does not involve an account DID +func (a *AdminAuth) AccountDID() syntax.DID { + return "" +} diff --git a/atproto/client/api_client.go b/atproto/client/api_client.go new file mode 100644 index 000000000..e3b7e9a3f --- /dev/null +++ b/atproto/client/api_client.go @@ -0,0 +1,65 @@ +package client + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// NOTE: this is an interface so it can be wrapped/extended. eg, a variant with a bunch of retries, or caching, or whatever. maybe that is too complex and we should have simple struct type, more like the existing `indigo/xrpc` package? hrm. + +type APIClient interface { + // Full-power method for making atproto API requests. + Do(ctx context.Context, req *APIRequest) (*http.Response, error) + + // High-level helper for simple JSON "Query" API calls. + // + // Does not work with all API endpoints. For more control, use the Do() method with APIRequest. + Get(ctx context.Context, endpoint syntax.NSID, params map[string]string) (*json.RawMessage, error) + + // High-level helper for simple JSON-to-JSON "Procedure" API calls. + // + // Does not work with all API endpoints. For more control, use the Do() method with APIRequest. + // TODO: what is the right type for body, to indicate it can be marshaled as JSON? + Post(ctx context.Context, endpoint syntax.NSID, body any) (*json.RawMessage, error) + + // Returns the currently-authenticated account DID, or empty string if not available. + AuthDID() syntax.DID +} + +type APIRequest struct { + HTTPVerb string // TODO: type? + Endpoint syntax.NSID + Body io.Reader + QueryParams map[string]string // TODO: better type for this? + Headers map[string]string +} + +func (r *APIRequest) HTTPRequest(ctx context.Context, host string, headers map[string]string) (*http.Request, error) { + // TODO: use 'url' to safely construct the request URL + u := host + "/xrpc/" + r.Endpoint.String() + // XXX: query params + httpReq, err := http.NewRequestWithContext(ctx, r.HTTPVerb, u, r.Body) + if err != nil { + return nil, err + } + + // first set default headers + if headers != nil { + for k, v := range headers { + httpReq.Header.Set(k, v) + } + } + + // then request-specific take priority (overwrite) + if r.Headers != nil { + for k, v := range r.Headers { + httpReq.Header.Set(k, v) + } + } + + return httpReq, nil +} diff --git a/atproto/client/auth_method.go b/atproto/client/auth_method.go new file mode 100644 index 000000000..9596da1bc --- /dev/null +++ b/atproto/client/auth_method.go @@ -0,0 +1,13 @@ +package client + +import ( + "context" + "net/http" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +type AuthMethod interface { + DoWithAuth(ctx context.Context, httpReq *http.Request, httpClient *http.Client) (*http.Response, error) + AccountDID() syntax.DID +} diff --git a/atproto/client/base_api_client.go b/atproto/client/base_api_client.go new file mode 100644 index 000000000..fe08cfcd9 --- /dev/null +++ b/atproto/client/base_api_client.go @@ -0,0 +1,107 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +type BaseAPIClient struct { + HTTPClient *http.Client + Host string + Auth AuthMethod + DefaultHeaders map[string]string +} + +func (c *BaseAPIClient) Get(ctx context.Context, endpoint syntax.NSID, params map[string]string) (*json.RawMessage, error) { + hdr := map[string]string{ + "Accept": "application/json", + } + req := APIRequest{ + HTTPVerb: "GET", + Endpoint: endpoint, + Body: nil, + QueryParams: params, + Headers: hdr, + } + resp, err := c.Do(ctx, req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + // TODO: duplicate error handling with Post()? + if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { + return nil, fmt.Errorf("non-successful API request status: %d", resp.StatusCode) + } + + var ret json.RawMessage + if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil { + return nil, fmt.Errorf("expected JSON response body: %w", err) + } + return &ret, nil +} + +func (c *BaseAPIClient) Post(ctx context.Context, endpoint syntax.NSID, body any) (*json.RawMessage, error) { + bodyJSON, err := json.Marshal(body) + if err != nil { + return nil, err + } + hdr := map[string]string{ + "Accept": "application/json", + "Content-Type": "application/json", + } + req := APIRequest{ + HTTPVerb: "POST", + Endpoint: endpoint, + Body: bytes.NewReader(bodyJSON), + QueryParams: nil, + Headers: hdr, + } + resp, err := c.Do(ctx, req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + // TODO: duplicate error handling with Get()? + if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { + return nil, fmt.Errorf("non-successful API request status: %d", resp.StatusCode) + } + + var ret json.RawMessage + if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil { + return nil, fmt.Errorf("expected JSON response body: %w", err) + } + return &ret, nil +} + +func (c *BaseAPIClient) Do(ctx context.Context, req APIRequest) (*http.Response, error) { + httpReq, err := req.HTTPRequest(ctx, c.Host, c.DefaultHeaders) + if err != nil { + return nil, err + } + + var resp *http.Response + if c.Auth != nil { + resp, err = c.Auth.DoWithAuth(ctx, httpReq, c.HTTPClient) + } else { + resp, err = c.HTTPClient.Do(httpReq) + } + if err != nil { + return nil, err + } + // TODO: handle some common response errors: rate-limits, 5xx, auth required, etc + return resp, nil +} + +func (c *BaseAPIClient) AuthDID() syntax.DID { + if c.Auth != nil { + return c.Auth.AccountDID() + } + return "" +} diff --git a/atproto/client/net_client.go b/atproto/client/net_client.go new file mode 100644 index 000000000..e5148bba9 --- /dev/null +++ b/atproto/client/net_client.go @@ -0,0 +1,39 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "io" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// API for clients which pull data from the public atproto network. +// +// 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. +type NetClient interface { + // Fetches record JSON, without verification or validation. A version (CID) can optionally be specified; use empty string to fetch the latest. + // 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. + GetRecordJSON(ctx context.Context, aturi syntax.ATURI, version syntax.CID) (*json.RawMessage, *syntax.CID, error) + + // 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. + // Returns the record as CBOR; the CID of the validated record, and the repo commit revision. + VerifyRecordCBOR(ctx context.Context, aturi syntax.ATURI, version syntax.CID) (*[]byte, *syntax.CID, string, error) + + // Fetches repo export (CAR file). Optionally attempts to fetch only the diff "since" an earlier repo revision. + GetRepoCAR(ctx context.Context, did syntax.DID, since string) (*io.Reader, error) + + // Fetches indicated blob. Does not validate the CID. Returns a reader (which calling code is responsible for closing). + GetBlob(ctx context.Context, did syntax.DID, cid syntax.CID) (*io.Reader, error) + CheckAccountStatus(ctx context.Context, did syntax.DID) (*AccountStatus, error) +} + +// XXX: type alias to codegen? or just copy? this is protocol-level +type AccountStatus struct { +} + +func VerifyBlobCID(blob []byte, cid syntax.CID) error { + // XXX: compute hash, check against provided CID + return errors.New("Not Implemented") +} diff --git a/atproto/client/refresh_auth.go b/atproto/client/refresh_auth.go new file mode 100644 index 000000000..508dd4519 --- /dev/null +++ b/atproto/client/refresh_auth.go @@ -0,0 +1,31 @@ +package client + +import ( + "context" + "net/http" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +type RefreshAuth struct { + AccessToken string + RefreshToken string + DID syntax.DID + // The AuthHost might different from any APIClient host, if there is an entryway involved + AuthHost string +} + +// TODO: +//func NewRefreshAuth(pdsHost, accountIdentifier, password string) (*RefreshAuth, error) { + +func (a *RefreshAuth) DoWithAuth(ctx context.Context, httpReq *http.Request, httpClient *http.Client) (*http.Response, error) { + httpReq.Header.Set("Authorization", "Bearer "+a.AccessToken) + // XXX: check response. if it is 403, because access token is expired, then take a lock and do a refresh + // TODO: when doing a refresh request, copy at least the User-Agent header from httpReq, and re-use httpClient + return httpClient.Do(httpReq) +} + +// Admin bearer token auth does not involve an account DID +func (a *RefreshAuth) AccountDID() syntax.DID { + return a.DID +}