fork of indigo with slightly nicer lexgen
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

progress on client

+355 -107
+6 -4
atproto/client/admin_auth.go
··· 8 8 Password string 9 9 } 10 10 11 - func NewAdminAuth(password string) AdminAuth { 12 - return AdminAuth{Password: password} 13 - } 14 - 15 11 func (a *AdminAuth) DoWithAuth(c *http.Client, req *http.Request) (*http.Response, error) { 16 12 req.SetBasicAuth("admin", a.Password) 17 13 return c.Do(req) 18 14 } 15 + 16 + func NewAdminClient(host, password string) *APIClient { 17 + c := NewPublicClient(host) 18 + c.Auth = &AdminAuth{Password: password} 19 + return c 20 + }
+55 -12
atproto/client/api_client.go
··· 13 13 14 14 type APIClient struct { 15 15 // inner HTTP client 16 - Client *http.Client 16 + Client *http.Client 17 17 18 18 // host URL prefix: scheme, hostname, and port. This field is required. 19 - Host string 19 + Host string 20 20 21 21 // optional auth client "middleware" 22 - Auth AuthMethod 22 + Auth AuthMethod 23 23 24 24 // optional HTTP headers which will be included in all requests. Only a single value per key is included; request-level headers will override any client-level defaults. 25 - DefaultHeaders http.Header 25 + Headers http.Header 26 + 27 + // optional authenticated account DID for this client. Does not change client behavior; this is included as a convenience for calling code, logging, etc. 28 + AccountDID *syntax.DID 29 + } 30 + 31 + func NewPublicClient(host string) *APIClient { 32 + return &APIClient{ 33 + Client: http.DefaultClient, 34 + Host: host, 35 + Headers: map[string][]string{ 36 + "User-Agent": []string{"indigo-sdk"}, 37 + }, 38 + } 26 39 } 27 40 28 41 // High-level helper for simple JSON "Query" API calls. ··· 110 123 // 111 124 // NOTE: this does not currently parse error response JSON body, thought it might in the future. 112 125 func (c *APIClient) Do(ctx context.Context, req APIRequest) (*http.Response, error) { 113 - httpReq, err := req.HTTPRequest(ctx, c.Host, c.DefaultHeaders) 126 + httpReq, err := req.HTTPRequest(ctx, c.Host, c.Headers) 114 127 if err != nil { 115 128 return nil, err 116 129 } ··· 132 145 return resp, nil 133 146 } 134 147 135 - func NewPublicClient(host string) *APIClient { 136 - return &APIClient{ 137 - Client: http.DefaultClient, 138 - Host: host, 139 - DefaultHeaders: map[string][]string{ 140 - "User-Agent": []string{"indigo-sdk"}, 141 - }, 148 + // Returns a shallow copy of the APIClient with the provided service ref configured as a proxy header. 149 + // 150 + // To configure service proxying without creating a copy, simply set the "Atproto-Proxy" header. 151 + func (c *APIClient) WithService(ref string) *APIClient { 152 + hdr := make(http.Header) 153 + for k := range c.Headers { 154 + for _, v := range c.Headers.Values(k) { 155 + hdr.Add(k, v) 156 + } 142 157 } 158 + 159 + hdr.Set("Atproto-Proxy", ref) 160 + out := APIClient{ 161 + Client: c.Client, 162 + Host: c.Host, 163 + Auth: c.Auth, 164 + Headers: hdr, 165 + AccountDID: c.AccountDID, 166 + } 167 + return &out 168 + } 169 + 170 + // Configures labeler header (Atproto-Accept-Labelers) with the indicated "redact" level labelers, and regular labelers. 171 + func (c *APIClient) SetLabelers(redact, other []syntax.DID) { 172 + val := "" 173 + for _, did := range redact { 174 + if val != "" { 175 + val = val + "," 176 + } 177 + val = fmt.Sprintf("%s%s;redact", val, did.String()) 178 + } 179 + for _, did := range other { 180 + if val != "" { 181 + val = val + "," 182 + } 183 + val = val + did.String() 184 + } 185 + c.Headers.Set("Atproto-Accept-Labelers", val) 143 186 }
+10 -7
atproto/client/api_request.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "io" 6 5 "fmt" 6 + "io" 7 7 "net/http" 8 8 "net/url" 9 9 ··· 20 20 21 21 type APIRequest struct { 22 22 // HTTP method, eg "GET" (required) 23 - Method string 23 + Method string 24 24 25 25 // atproto API endpoint, as NSID (required) 26 - Endpoint syntax.NSID 26 + Endpoint syntax.NSID 27 27 28 28 // optional request body. if this is provided, then 'Content-Type' header should be specified 29 - Body io.Reader 29 + Body io.Reader 30 + 31 + // XXX: 32 + //GetBody func() (io.ReadCloser, error) 30 33 31 34 // optional query parameters. These will be encoded as provided. 32 35 QueryParams url.Values 33 36 34 37 // optional HTTP headers. Only the first value will be included for each header key ("Set" behavior). 35 - Headers http.Header 38 + Headers http.Header 36 39 } 37 40 38 41 // Turns the API request in to an `http.Request`. ··· 54 57 return nil, fmt.Errorf("empty request endpoint") 55 58 } 56 59 u.Path = "/xrpc/" + r.Endpoint.String() 57 - u.RawQuery = nil 60 + u.RawQuery = "" 58 61 if r.QueryParams != nil { 59 62 u.RawQuery = r.QueryParams.Encode() 60 63 } ··· 73 76 // ... then request-specific take priority (overwrite) 74 77 if r.Headers != nil { 75 78 for k := range r.Headers { 76 - httpReq.Header.Set(k, headers.Get(k)) 79 + httpReq.Header.Set(k, r.Headers.Get(k)) 77 80 } 78 81 } 79 82
+120 -20
atproto/client/cmd/atclient/main.go
··· 20 20 Usage: "dev helper for atproto/client SDK", 21 21 Commands: []*cli.Command{ 22 22 &cli.Command{ 23 - Name: "get", 24 - Usage: "do a basic GET request", 25 - Action: runGet, 23 + Name: "get-feed-public", 24 + Usage: "do a basic GET request (getAuthorFeed)", 25 + Action: runGetFeedPublic, 26 + Flags: []cli.Flag{ 27 + &cli.StringFlag{ 28 + Name: "host", 29 + Value: "https://public.api.bsky.app", 30 + Usage: "service host", 31 + }, 32 + }, 26 33 }, 27 34 &cli.Command{ 28 - Name: "login-refresh", 29 - Usage: "do a basic login and GET request", 30 - Action: runLoginRefresh, 35 + Name: "login-auth", 36 + Usage: "do a basic login and GET session info", 37 + Action: runLoginAuth, 31 38 Flags: []cli.Flag{ 32 39 &cli.StringFlag{ 33 40 Name: "username", ··· 43 50 }, 44 51 }, 45 52 }, 53 + &cli.Command{ 54 + Name: "get-feed-auth", 55 + Usage: "basic authenticated GET request", 56 + Action: runGetFeedAuth, 57 + Flags: []cli.Flag{ 58 + &cli.StringFlag{ 59 + Name: "username", 60 + Required: true, 61 + Aliases: []string{"u"}, 62 + Usage: "handle or DID (not email)", 63 + }, 64 + &cli.StringFlag{ 65 + Name: "password", 66 + Required: true, 67 + Aliases: []string{"p"}, 68 + Usage: "password (or app password)", 69 + }, 70 + &cli.StringFlag{ 71 + Name: "labelers", 72 + }, 73 + &cli.StringFlag{ 74 + Name: "appview", 75 + Value: "did:web:api.bsky.app#bsky_appview", 76 + Usage: "bsky appview service DID ref", 77 + }, 78 + }, 79 + }, 80 + &cli.Command{ 81 + Name: "lookup-admin", 82 + Usage: "basic PDS admin auth request (getAccountInfo)", 83 + Action: runLookupAdmin, 84 + Flags: []cli.Flag{ 85 + &cli.StringFlag{ 86 + Name: "admin-password", 87 + Required: true, 88 + Aliases: []string{"p"}, 89 + Usage: "admin auth password", 90 + }, 91 + &cli.StringFlag{ 92 + Name: "host", 93 + Required: true, 94 + Usage: "service host", 95 + }, 96 + &cli.StringFlag{ 97 + Name: "did", 98 + Required: true, 99 + Usage: "account DID to lookup", 100 + }, 101 + }, 102 + }, 46 103 }, 47 104 } 48 105 h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}) ··· 51 108 } 52 109 53 110 func simpleGet(ctx context.Context, c *client.APIClient) error { 54 - params := map[string]string{ 55 - "actor": "atproto.com", 56 - "limit": "5", 57 - "includePins": "false", 111 + params := map[string][]string{ 112 + "actor": []string{"atproto.com"}, 113 + "limit": []string{"2"}, 114 + "includePins": []string{"false"}, 58 115 } 59 116 60 117 var d json.RawMessage ··· 71 128 return nil 72 129 } 73 130 74 - func runGet(cctx *cli.Context) error { 75 - ctx := context.Background() 131 + func runGetFeedPublic(cctx *cli.Context) error { 132 + ctx := cctx.Context 76 133 77 134 c := client.APIClient{ 78 - Host: "https://public.api.bsky.app", 135 + Host: cctx.String("host"), 79 136 } 80 137 81 138 return simpleGet(ctx, &c) 82 139 } 83 140 84 - func runLoginRefresh(cctx *cli.Context) error { 85 - ctx := context.Background() 141 + func runLoginAuth(cctx *cli.Context) error { 142 + ctx := cctx.Context 86 143 87 144 atid, err := syntax.ParseAtIdentifier(cctx.String("username")) 88 145 if err != nil { ··· 90 147 } 91 148 92 149 dir := identity.DefaultDirectory() 93 - ident, err := dir.Lookup(ctx, *atid) 150 + 151 + c, err := client.LoginWithPassword(ctx, dir, *atid, cctx.String("password"), "") 94 152 if err != nil { 95 153 return err 96 154 } 97 155 98 - c := client.APIClient{ 99 - Host: ident.PDSEndpoint(), 156 + var d json.RawMessage 157 + err = c.Get(ctx, "com.atproto.server.getSession", nil, &d) 158 + if err != nil { 159 + return err 100 160 } 101 161 102 - _, err = client.NewSession(ctx, &c, atid.String(), cctx.String("password"), "") 162 + out, err := json.MarshalIndent(d, "", " ") 103 163 if err != nil { 104 164 return err 105 165 } 166 + fmt.Println(string(out)) 167 + return nil 168 + } 106 169 107 - return simpleGet(ctx, &c) 170 + func runGetFeedAuth(cctx *cli.Context) error { 171 + ctx := cctx.Context 172 + 173 + atid, err := syntax.ParseAtIdentifier(cctx.String("username")) 174 + if err != nil { 175 + return err 176 + } 177 + 178 + dir := identity.DefaultDirectory() 179 + 180 + c, err := client.LoginWithPassword(ctx, dir, *atid, cctx.String("password"), "") 181 + if err != nil { 182 + return err 183 + } 184 + c = c.WithService(cctx.String("appview")) 185 + 186 + return simpleGet(ctx, c) 187 + } 188 + 189 + func runLookupAdmin(cctx *cli.Context) error { 190 + ctx := cctx.Context 191 + 192 + c := client.NewAdminClient(cctx.String("host"), cctx.String("admin-password")) 193 + 194 + var d json.RawMessage 195 + params := map[string][]string{ 196 + "did": []string{cctx.String("did")}, 197 + } 198 + if err := c.Get(ctx, "com.atproto.admin.getAccountInfo", params, &d); err != nil { 199 + return err 200 + } 201 + 202 + out, err := json.MarshalIndent(d, "", " ") 203 + if err != nil { 204 + return err 205 + } 206 + fmt.Println(string(out)) 207 + return nil 108 208 }
+164
atproto/client/password_auth.go
··· 1 + package client 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "strings" 9 + "sync" 10 + 11 + comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/identity" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + ) 15 + 16 + type PasswordAuth struct { 17 + Session SessionData 18 + // TODO: RefreshCallback 19 + 20 + lk sync.Mutex 21 + } 22 + 23 + type SessionData struct { 24 + AccessToken string 25 + RefreshToken string 26 + AccountDID syntax.DID 27 + Host string 28 + } 29 + 30 + func (a *PasswordAuth) DoWithAuth(c *http.Client, req *http.Request) (*http.Response, error) { 31 + req.Header.Set("Authorization", "Bearer "+a.Session.AccessToken) 32 + resp, err := c.Do(req) 33 + if err != nil { 34 + return nil, err 35 + } 36 + 37 + // on success, or most errors, just return HTTP response 38 + if resp.StatusCode != 400 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { 39 + return resp, nil 40 + } 41 + 42 + // parse the error response body (JSON) and check the error name 43 + defer resp.Body.Close() 44 + var eb ErrorBody 45 + if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { 46 + return nil, &APIError{StatusCode: resp.StatusCode} 47 + } 48 + if eb.Name != "ExpiredToken" { 49 + return nil, eb.APIError(resp.StatusCode) 50 + } 51 + 52 + // ok, we had an expired token, try a refresh 53 + if err := a.Refresh(req.Context(), c); err != nil { 54 + return nil, err 55 + } 56 + 57 + // XXX: review "retry" logic (HTTP body, headers, etc) 58 + req.Header.Set("Authorization", "Bearer "+a.Session.AccessToken) 59 + resp, err = c.Do(req) 60 + if err != nil { 61 + return nil, err 62 + } 63 + // XXX: handle auth error here? 64 + return resp, err 65 + } 66 + 67 + // TODO: need a "Logout" method as well? which takes the refresh token (not access token) 68 + func (a *PasswordAuth) Refresh(ctx context.Context, c *http.Client) error { 69 + 70 + prior := a.Session.RefreshToken 71 + 72 + a.lk.Lock() 73 + defer a.lk.Unlock() 74 + 75 + // XXX: basic concurrency check: if refresh token already changed, can bail here. should probably handle this better (accept refresh token as input?) 76 + if prior != a.Session.RefreshToken { 77 + return nil 78 + } 79 + 80 + u := a.Session.Host + "/xrpc/com.atproto.server.refreshSession" 81 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) 82 + if err != nil { 83 + return err 84 + } 85 + // TODO: this doesn't inherit User-Agent header 86 + req.Header.Set("User-Agent", "indigo-sdk") 87 + 88 + // NOTE: using refresh token here, not access token 89 + req.Header.Set("Authorization", "Bearer "+a.Session.RefreshToken) 90 + 91 + resp, err := c.Do(req) 92 + if err != nil { 93 + return err 94 + } 95 + defer resp.Body.Close() 96 + 97 + if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { 98 + var eb ErrorBody 99 + if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { 100 + return &APIError{StatusCode: resp.StatusCode} 101 + } 102 + // TODO: indicate in this error that it was from refresh process, not original request? 103 + return eb.APIError(resp.StatusCode) 104 + } 105 + 106 + var out comatproto.ServerRefreshSession_Output 107 + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { 108 + return err 109 + } 110 + 111 + a.Session.AccessToken = out.AccessJwt 112 + a.Session.RefreshToken = out.RefreshJwt 113 + // TODO: callback? 114 + 115 + return nil 116 + } 117 + 118 + func LoginWithPassword(ctx context.Context, dir identity.Directory, username syntax.AtIdentifier, password, authToken string) (*APIClient, error) { 119 + 120 + ident, err := dir.Lookup(ctx, username) 121 + if err != nil { 122 + return nil, err 123 + } 124 + 125 + host := ident.PDSEndpoint() 126 + if host == "" { 127 + return nil, fmt.Errorf("account does not have PDS registered") 128 + } 129 + 130 + c := NewPublicClient(host) 131 + reqBody := comatproto.ServerCreateSession_Input{ 132 + Identifier: ident.DID.String(), 133 + Password: password, 134 + } 135 + if authToken != "" { 136 + reqBody.AuthFactorToken = &authToken 137 + } 138 + 139 + // TODO: copy/vendor in session objects 140 + var out comatproto.ServerCreateSession_Output 141 + if err := c.Post(ctx, syntax.NSID("com.atproto.server.createSession"), &reqBody, &out); err != nil { 142 + return nil, err 143 + } 144 + 145 + if out.Active != nil && *out.Active == false { 146 + return nil, fmt.Errorf("account is disabled: %v", out.Status) 147 + } 148 + 149 + if out.Did != ident.DID.String() { 150 + return nil, fmt.Errorf("returned session DID not requested account: %s", out.Did) 151 + } 152 + 153 + ra := PasswordAuth{ 154 + Session: SessionData{ 155 + AccessToken: out.AccessJwt, 156 + RefreshToken: out.RefreshJwt, 157 + AccountDID: ident.DID, 158 + Host: c.Host, 159 + }, 160 + } 161 + c.Auth = &ra 162 + c.AccountDID = &ident.DID 163 + return c, nil 164 + }
-64
atproto/client/refresh_auth.go
··· 1 - package client 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - "sync" 8 - 9 - comatproto "github.com/bluesky-social/indigo/api/atproto" 10 - 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - ) 13 - 14 - type RefreshAuth struct { 15 - AccessToken string 16 - RefreshToken string 17 - DID syntax.DID 18 - // The AuthHost might different from any APIClient host, if there is an entryway involved 19 - AuthHost string 20 - 21 - lk sync.Mutex 22 - } 23 - 24 - // TODO: 25 - //func NewRefreshAuth(pdsHost, accountIdentifier, password string) (*RefreshAuth, error) { 26 - 27 - func (a *RefreshAuth) DoWithAuth(c *http.Client, req *http.Request) (*http.Response, error) { 28 - req.Header.Set("Authorization", "Bearer "+a.AccessToken) 29 - // XXX: check response. if it is 403, because access token is expired, then take a lock and do a refresh 30 - // TODO: when doing a refresh request, copy at least the User-Agent header from httpReq, and re-use httpClient 31 - return c.Do(req) 32 - } 33 - 34 - // updates the client with the new auth method 35 - func NewSession(ctx context.Context, client *APIClient, username, password, token string) (*RefreshAuth, error) { 36 - 37 - reqBody := comatproto.ServerCreateSession_Input{ 38 - Identifier: username, 39 - Password: password, 40 - } 41 - if token != "" { 42 - reqBody.AuthFactorToken = &token 43 - } 44 - 45 - var out comatproto.ServerCreateSession_Output 46 - err := client.Post(ctx, syntax.NSID("com.atproto.server.createSession"), &reqBody, &out) 47 - if err != nil { 48 - return nil, err 49 - } 50 - 51 - if out.Active != nil && *out.Active == false { 52 - return nil, fmt.Errorf("account is disabled: %v", out.Status) 53 - } 54 - 55 - ra := RefreshAuth{ 56 - AccessToken: out.AccessJwt, 57 - RefreshToken: out.RefreshJwt, 58 - DID: syntax.DID(out.Did), 59 - // TODO: authHost / PDS host distinction 60 - AuthHost: client.Host, 61 - } 62 - client.Auth = &ra 63 - return &ra, nil 64 - }