this repo has no description
0
fork

Configure Feed

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

Merge branch 'main' into sync-tool

dholms 29c67a17 6f77b020

+488 -6163
-1
.gitignore
··· 26 26 /bigsky 27 27 /bluepages 28 28 /fakermaker 29 - /goat 30 29 /gosky 31 30 /hepa 32 31 /lexgen
+2 -2
HACKING.md
··· 26 26 - `api/bsky`: generated types for `app.bsky` lexicon 27 27 - `api/chat`: generated types for `chat.bsky` lexicon 28 28 - `api/ozone`: generated types for `tools.ozone` lexicon 29 - - `atproto/crypto`: cryptographic helpers (signing, key generation and serialization) 29 + - `atproto/atcrypto`: cryptographic helpers (signing, key generation and serialization) 30 30 - `atproto/syntax`: string types and parsers for identifiers, datetimes, etc 31 31 - `atproto/identity`: DID and handle resolution 32 - - `atproto/data`: helpers for atproto data as JSON or CBOR with unknown schema 32 + - `atproto/atdata`: helpers for atproto data as JSON or CBOR with unknown schema 33 33 - `atproto/lexicon`: lexicon validation of generic data 34 34 - `atproto/repo`: repo and MST implementation 35 35 - `automod`: moderation and anti-spam rules engine
-1
Makefile
··· 16 16 17 17 .PHONY: build 18 18 build: ## Build all executables 19 - go build ./cmd/goat 20 19 go build ./cmd/gosky 21 20 go build ./cmd/bigsky 22 21 go build ./cmd/relay
+3 -1
README.md
··· 25 25 | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 26 26 | `api/atproto`: generated types for `com.atproto.*` Lexicons | [![PkgGoDev](https://pkg.go.dev/badge/mod/github.com/bluesky-social/indigo/api/atproto)](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/api/atproto) | 27 27 | `api/bsky`: generated types for `app.bsky.*` Lexicons | [![PkgGoDev](https://pkg.go.dev/badge/mod/github.com/bluesky-social/indigo/api/bsky)](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/api/bsky) | 28 - | `atproto/crypto`: crytographic signing and key serialization | [![PkgGoDev](https://pkg.go.dev/badge/mod/github.com/bluesky-social/indigo/atproto/crypto)](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/atproto/crypto) | 28 + | `atproto/atcrypto`: crytographic signing and key serialization | [![PkgGoDev](https://pkg.go.dev/badge/mod/github.com/bluesky-social/indigo/atproto/atcrypto)](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/atproto/atcrypto) | 29 29 | `atproto/identity`: DID and handle resolution | [![PkgGoDev](https://pkg.go.dev/badge/mod/github.com/bluesky-social/indigo/atproto/identity)](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/atproto/identity) | 30 30 | `atproto/syntax`: string types and parsers for identifiers | [![PkgGoDev](https://pkg.go.dev/badge/mod/github.com/bluesky-social/indigo/atproto/syntax)](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/atproto/syntax) | 31 31 | `atproto/lexicon`: schema validation of data | [![PkgGoDev](https://pkg.go.dev/badge/mod/github.com/bluesky-social/indigo/atproto/lexicon)](https://pkg.go.dev/mod/github.com/bluesky-social/indigo/atproto/lexicon) | ··· 103 103 - Apache License, Version 2.0, ([LICENSE-APACHE](https://github.com/bluesky-social/indigo/blob/main/LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 104 104 105 105 Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0. 106 + 107 + Bluesky Social PBC has committed to a software patent non-aggression pledge. For details see [the original announcement](https://bsky.social/about/blog/10-01-2025-patent-pledge).
+2 -2
atproto/auth/http_test.go
··· 6 6 "testing" 7 7 "time" 8 8 9 - "github.com/bluesky-social/indigo/atproto/crypto" 9 + "github.com/bluesky-social/indigo/atproto/atcrypto" 10 10 "github.com/bluesky-social/indigo/atproto/identity" 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 ··· 78 78 aud := "did:example:aud#svc" 79 79 lxm := syntax.NSID("com.example.api") 80 80 81 - priv, err := crypto.GeneratePrivateKeyP256() 81 + priv, err := atcrypto.GeneratePrivateKeyP256() 82 82 require.NoError(err) 83 83 pub, err := priv.PublicKey() 84 84 require.NoError(err)
+5 -5
atproto/auth/jwt.go
··· 9 9 "log/slog" 10 10 "time" 11 11 12 - "github.com/bluesky-social/indigo/atproto/crypto" 12 + "github.com/bluesky-social/indigo/atproto/atcrypto" 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 ··· 122 122 return base64.RawURLEncoding.EncodeToString(buf) 123 123 } 124 124 125 - func SignServiceAuth(iss syntax.DID, aud string, ttl time.Duration, lexMethod *syntax.NSID, priv crypto.PrivateKey) (string, error) { 125 + func SignServiceAuth(iss syntax.DID, aud string, ttl time.Duration, lexMethod *syntax.NSID, priv atcrypto.PrivateKey) (string, error) { 126 126 claims := serviceAuthClaims{ 127 127 RegisteredClaims: jwt.RegisteredClaims{ 128 128 ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), ··· 138 138 139 139 var sm *signingMethodAtproto 140 140 141 - // NOTE: could also have a crypto.PrivateKey.Alg() method which returns a string 141 + // NOTE: could also have a atcrypto.PrivateKey.Alg() method which returns a string 142 142 switch priv.(type) { 143 - case *crypto.PrivateKeyP256: 143 + case *atcrypto.PrivateKeyP256: 144 144 sm = signingMethodES256 145 - case *crypto.PrivateKeyK256: 145 + case *atcrypto.PrivateKeyK256: 146 146 sm = signingMethodES256K 147 147 default: 148 148 return "", fmt.Errorf("unknown signing key type: %T", priv)
+2 -2
atproto/auth/jwt_signing.go
··· 3 3 import ( 4 4 "crypto" 5 5 6 - atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 6 + "github.com/bluesky-social/indigo/atproto/atcrypto" 7 7 "github.com/golang-jwt/jwt/v5" 8 8 ) 9 9 ··· 13 13 supportedAlgs []string 14 14 ) 15 15 16 - // Implementation of jwt.SigningMethod for the `atproto/crypto` types. 16 + // Implementation of jwt.SigningMethod for the `atproto/atcrypto` types. 17 17 type signingMethodAtproto struct { 18 18 alg string 19 19 hash crypto.Hash
+7 -7
atproto/auth/jwt_test.go
··· 6 6 "testing" 7 7 "time" 8 8 9 - "github.com/bluesky-social/indigo/atproto/crypto" 9 + "github.com/bluesky-social/indigo/atproto/atcrypto" 10 10 "github.com/bluesky-social/indigo/atproto/identity" 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 ··· 19 19 return time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) 20 20 } 21 21 22 - func validateMinimal(token string, iss, aud string, pub crypto.PublicKey) error { 22 + func validateMinimal(token string, iss, aud string, pub atcrypto.PublicKey) error { 23 23 24 24 p := jwt.NewParser( 25 25 jwt.WithValidMethods(supportedAlgs), ··· 71 71 72 72 for _, fix := range jwtTestFixtures { 73 73 74 - pubk, err := crypto.ParsePublicDIDKey(fix.pubkey) 74 + pubk, err := atcrypto.ParsePublicDIDKey(fix.pubkey) 75 75 if err != nil { 76 76 t.Fatal(err) 77 77 } ··· 80 80 } 81 81 } 82 82 83 - func testSigningValidation(t *testing.T, priv crypto.PrivateKey) { 83 + func testSigningValidation(t *testing.T, priv atcrypto.PrivateKey) { 84 84 assert := assert.New(t) 85 85 ctx := context.Background() 86 86 ··· 88 88 aud := "did:example:aud#svc" 89 89 lxm := syntax.NSID("com.example.api") 90 90 91 - priv, err := crypto.GeneratePrivateKeyP256() 91 + priv, err := atcrypto.GeneratePrivateKeyP256() 92 92 if err != nil { 93 93 t.Fatal(err) 94 94 } ··· 140 140 } 141 141 142 142 func TestP256SigningValidation(t *testing.T) { 143 - priv, err := crypto.GeneratePrivateKeyP256() 143 + priv, err := atcrypto.GeneratePrivateKeyP256() 144 144 if err != nil { 145 145 t.Fatal(err) 146 146 } ··· 148 148 } 149 149 150 150 func TestK256SigningValidation(t *testing.T) { 151 - priv, err := crypto.GeneratePrivateKeyK256() 151 + priv, err := atcrypto.GeneratePrivateKeyK256() 152 152 if err != nil { 153 153 t.Fatal(err) 154 154 }
+4 -4
atproto/auth/oauth/cmd/oauth-web-demo/main.go
··· 12 12 13 13 _ "github.com/joho/godotenv/autoload" 14 14 15 + "github.com/bluesky-social/indigo/atproto/atcrypto" 15 16 "github.com/bluesky-social/indigo/atproto/auth/oauth" 16 - "github.com/bluesky-social/indigo/atproto/crypto" 17 17 "github.com/bluesky-social/indigo/atproto/identity" 18 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 19 ··· 101 101 102 102 // If a client secret key is provided (as a multibase string), turn this in to a confidential client 103 103 if cctx.String("client-secret-key") != "" && hostname != "" { 104 - priv, err := crypto.ParsePrivateMultibase(cctx.String("client-secret-key")) 104 + priv, err := atcrypto.ParsePrivateMultibase(cctx.String("client-secret-key")) 105 105 if err != nil { 106 106 return err 107 107 } ··· 294 294 295 295 func (s *Server) OAuthLogout(w http.ResponseWriter, r *http.Request) { 296 296 297 - // delete session from auth store 297 + // revoke tokens and delete session from auth store 298 298 did, sessionID := s.currentSessionDID(r) 299 299 if did != nil { 300 - if err := s.OAuth.Store.DeleteSession(r.Context(), *did, sessionID); err != nil { 300 + if err := s.OAuth.Logout(r.Context(), *did, sessionID); err != nil { 301 301 slog.Error("failed to delete session", "did", did, "err", err) 302 302 } 303 303 }
+2 -2
atproto/auth/oauth/doc.go
··· 122 122 123 123 The [ClientSession] will handle nonce updates and token refreshes, and persist the results in the [ClientAuthStore]. 124 124 125 - To log out a user, delete their session from the [ClientAuthStore]: 125 + To log out a user, use the [ClientApp.Logout] helper method, which revokes their tokens (if supported by the AS) and deletes their session from the [ClientAuthStore]: 126 126 127 - if err := oauthApp.Store.DeleteSession(r.Context(), did, sessionID); err != nil { 127 + if err := oauthApp.Logout(r.Context(), did, sessionID); err != nil { 128 128 return err 129 129 } 130 130
+2 -2
atproto/auth/oauth/jwt_signing.go
··· 4 4 "crypto" 5 5 "fmt" 6 6 7 - atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 7 + "github.com/bluesky-social/indigo/atproto/atcrypto" 8 8 "github.com/golang-jwt/jwt/v5" 9 9 ) 10 10 ··· 15 15 supportedAlgs []string 16 16 ) 17 17 18 - // Implementation of jwt.SigningMethod for the `atproto/crypto` types. 18 + // Implementation of jwt.SigningMethod for the `atproto/atcrypto` types. 19 19 type signingMethodAtproto struct { 20 20 alg string 21 21 hash crypto.Hash
+5
atproto/auth/oauth/memstore.go
··· 74 74 m.lk.Lock() 75 75 defer m.lk.Unlock() 76 76 77 + if _, ok := m.requests[info.State]; ok { 78 + // Should be unreachable, barring implementation bugs elsewhere 79 + return fmt.Errorf("auth request already saved for state %s", info.State) 80 + } 81 + 77 82 m.requests[info.State] = info 78 83 return nil 79 84 }
+58 -30
atproto/auth/oauth/oauth.go
··· 11 11 "strings" 12 12 "time" 13 13 14 - "github.com/bluesky-social/indigo/atproto/crypto" 14 + "github.com/bluesky-social/indigo/atproto/atcrypto" 15 15 "github.com/bluesky-social/indigo/atproto/identity" 16 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 17 ··· 43 43 UserAgent string 44 44 45 45 // For confidential clients, the private client assertion key. Note that while an interface is used here, only P-256 is allowed by the current specification. 46 - PrivateKey crypto.PrivateKey 46 + PrivateKey atcrypto.PrivateKey 47 47 48 48 // ID for current client assertion key (should be provided if PrivateKey is) 49 49 KeyID *string ··· 112 112 return config.PrivateKey != nil && config.KeyID != nil 113 113 } 114 114 115 - func (config *ClientConfig) SetClientSecret(priv crypto.PrivateKey, keyID string) error { 115 + func (config *ClientConfig) SetClientSecret(priv atcrypto.PrivateKey, keyID string) error { 116 116 switch priv.(type) { 117 - case *crypto.PrivateKeyP256: 117 + case *atcrypto.PrivateKeyP256: 118 118 // pass 119 - case *crypto.PrivateKeyK256: 119 + case *atcrypto.PrivateKeyK256: 120 120 return fmt.Errorf("only P-256 (ES256) private keys supported for atproto OAuth") 121 121 default: 122 122 return fmt.Errorf("unknown private key type: %T", priv) ··· 131 131 // If the client does not have any keys (eg, public client), returns an empty set. 132 132 func (config *ClientConfig) PublicJWKS() JWKS { 133 133 134 - jwks := JWKS{Keys: []crypto.JWK{}} 134 + jwks := JWKS{Keys: []atcrypto.JWK{}} 135 135 136 136 // public client with no keys 137 137 if config.PrivateKey == nil || config.KeyID == nil { ··· 148 148 } 149 149 jwk.KeyID = config.KeyID 150 150 151 - jwks.Keys = []crypto.JWK{*jwk} 151 + jwks.Keys = []atcrypto.JWK{*jwk} 152 152 return jwks 153 153 } 154 154 ··· 209 209 } 210 210 211 211 // TODO: refactor this in to ClientAuthStore layer? 212 - priv, err := crypto.ParsePrivateMultibase(sd.DPoPPrivateKeyMultibase) 212 + priv, err := atcrypto.ParsePrivateMultibase(sd.DPoPPrivateKeyMultibase) 213 213 if err != nil { 214 214 return nil, err 215 215 } ··· 264 264 // Creates a DPoP token (JWT) for use with an OAuth Auth Server (not to be used with Resource Server). The returned JWT is not bound to an Access Token (no 'ath'), and does not indicate an issuer ('iss'). 265 265 // 266 266 // This is used during initial auth request (PAR), initial token request, and subsequent refresh token requests. Note that a full [ClientSession] is not available in several of these circumstances, so this is a stand-alone function. 267 - func NewAuthDPoP(httpMethod, url, dpopNonce string, privKey crypto.PrivateKey) (string, error) { 267 + func NewAuthDPoP(httpMethod, url, dpopNonce string, privKey atcrypto.PrivateKey) (string, error) { 268 268 269 269 claims := dpopClaims{ 270 270 HTTPMethod: httpMethod, ··· 356 356 dpopServerNonce := "" 357 357 358 358 // create new key for the session 359 - dpopPrivKey, err := crypto.GeneratePrivateKeyP256() 359 + dpopPrivKey, err := atcrypto.GeneratePrivateKeyP256() 360 360 if err != nil { 361 361 return nil, err 362 362 } ··· 413 413 } 414 414 415 415 parInfo := AuthRequestData{ 416 - State: state, 417 - AuthServerURL: authMeta.Issuer, 418 - Scopes: scopes, 419 - PKCEVerifier: pkceVerifier, 420 - RequestURI: parResp.RequestURI, 421 - AuthServerTokenEndpoint: authMeta.TokenEndpoint, 422 - DPoPAuthServerNonce: dpopServerNonce, 423 - DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(), 416 + State: state, 417 + AuthServerURL: authMeta.Issuer, 418 + Scopes: scopes, 419 + PKCEVerifier: pkceVerifier, 420 + RequestURI: parResp.RequestURI, 421 + AuthServerTokenEndpoint: authMeta.TokenEndpoint, 422 + AuthServerRevocationEndpoint: authMeta.RevocationEndpoint, 423 + DPoPAuthServerNonce: dpopServerNonce, 424 + DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(), 424 425 } 425 426 426 427 return &parInfo, nil ··· 446 447 body.ClientAssertion = &clientAssertion 447 448 } 448 449 449 - dpopPrivKey, err := crypto.ParsePrivateMultibase(info.DPoPPrivateKeyMultibase) 450 + dpopPrivKey, err := atcrypto.ParsePrivateMultibase(info.DPoPPrivateKeyMultibase) 450 451 if err != nil { 451 452 return nil, err 452 453 } ··· 668 669 } 669 670 670 671 sessData := ClientSessionData{ 671 - AccountDID: accountDID, 672 - SessionID: info.State, 673 - HostURL: hostURL, 674 - AuthServerURL: info.AuthServerURL, 675 - AuthServerTokenEndpoint: info.AuthServerTokenEndpoint, 676 - Scopes: strings.Split(tokenResp.Scope, " "), 677 - AccessToken: tokenResp.AccessToken, 678 - RefreshToken: tokenResp.RefreshToken, 679 - DPoPAuthServerNonce: info.DPoPAuthServerNonce, 680 - DPoPHostNonce: info.DPoPAuthServerNonce, // bootstrap host nonce from authserver 681 - DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase, 672 + AccountDID: accountDID, 673 + SessionID: info.State, 674 + HostURL: hostURL, 675 + AuthServerURL: info.AuthServerURL, 676 + AuthServerTokenEndpoint: info.AuthServerTokenEndpoint, 677 + AuthServerRevocationEndpoint: info.AuthServerRevocationEndpoint, 678 + Scopes: strings.Split(tokenResp.Scope, " "), 679 + AccessToken: tokenResp.AccessToken, 680 + RefreshToken: tokenResp.RefreshToken, 681 + DPoPAuthServerNonce: info.DPoPAuthServerNonce, 682 + DPoPHostNonce: info.DPoPAuthServerNonce, // bootstrap host nonce from authserver 683 + DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase, 682 684 } 683 685 if err := app.Store.SaveSession(ctx, sessData); err != nil { 684 686 return nil, err ··· 689 691 } 690 692 return &sessData, nil 691 693 } 694 + 695 + // High-level helper to delete a session, including revoking access/refresh tokens if supported by the AS 696 + func (app *ClientApp) Logout(ctx context.Context, did syntax.DID, sessionID string) error { 697 + sess, err := app.ResumeSession(ctx, did, sessionID) 698 + if err != nil { 699 + return err 700 + } 701 + 702 + // Tell the AS to revoke the tokens, if supported 703 + if sess.Data.AuthServerRevocationEndpoint == "" { 704 + slog.Info("AS does not support token revocation, skipping RevokeSession") 705 + } else { 706 + err = sess.RevokeSession(ctx) 707 + if err != nil { 708 + slog.Warn("error during session revocation", "err", err) 709 + } 710 + } 711 + 712 + // Delete from our own session store 713 + err = app.Store.DeleteSession(ctx, did, sessionID) 714 + if err != nil { 715 + return err 716 + } 717 + 718 + return nil 719 + }
+90 -34
atproto/auth/oauth/session.go
··· 4 4 "bytes" 5 5 "context" 6 6 "encoding/json" 7 + "errors" 7 8 "fmt" 8 9 "log/slog" 9 10 "net/http" ··· 12 13 "sync" 13 14 "time" 14 15 15 - "github.com/bluesky-social/indigo/atproto/client" 16 - "github.com/bluesky-social/indigo/atproto/crypto" 16 + "github.com/bluesky-social/indigo/atproto/atclient" 17 + "github.com/bluesky-social/indigo/atproto/atcrypto" 17 18 "github.com/bluesky-social/indigo/atproto/syntax" 18 19 19 20 "github.com/golang-jwt/jwt/v5" ··· 38 39 39 40 // Full token endpoint 40 41 AuthServerTokenEndpoint string `json:"authserver_token_endpoint"` 42 + 43 + // Full revocation endpoint, if it exists 44 + AuthServerRevocationEndpoint string `json:"authserver_revocation_endpoint,omitempty"` 41 45 42 46 // The set of scopes approved for this session (returned in the initial token request) 43 47 Scopes []string `json:"scopes"` ··· 60 64 // TODO: also persist access token creation time / expiration time? In context that token might not be an easily parsed JWT 61 65 } 62 66 63 - // Implementation of [client.AuthMethod] for an OAuth session. Handles DPoP request token signing and nonce rotation, and token refresh requests. Optionally uses a callback to persist updated session data. 67 + // Implementation of [atclient.AuthMethod] for an OAuth session. Handles DPoP request token signing and nonce rotation, and token refresh requests. Optionally uses a callback to persist updated session data. 64 68 // 65 69 // A single ClientSession instance can be called concurrently: updates to session data (the 'Data' field) are protected with a RW mutex lock. Note that concurrent calls to distinct ClientSession instances for the same session could result in clobbered session data. 66 70 type ClientSession struct { ··· 69 73 70 74 Config *ClientConfig 71 75 Data *ClientSessionData 72 - DPoPPrivateKey crypto.PrivateKey 76 + DPoPPrivateKey atcrypto.PrivateKey 73 77 74 78 PersistSessionCallback PersistSessionCallback 75 79 ··· 77 81 lk sync.RWMutex 78 82 } 79 83 80 - // Requests new tokens from auth server, and returns the new access token on success. 81 - // 82 - // Internally takes a lock on session data around the entire refresh process, including retries. Persists data using [PersistSessionCallback] if configured. 83 - func (sess *ClientSession) RefreshTokens(ctx context.Context) (string, error) { 84 - sess.lk.Lock() 85 - defer sess.lk.Unlock() 86 - 87 - body := RefreshTokenRequest{ 88 - ClientID: sess.Config.ClientID, 89 - GrantType: "refresh_token", 90 - RefreshToken: sess.Data.RefreshToken, 84 + // Helper method to handle DPoP retries and client assertions (if the client is confidential) 85 + // body object will be url-encoded (expected to be either RefreshTokenRequest or RevocationRequest) 86 + // expects sess.lk to be held by caller 87 + // if a non-nil *http.Response is returned, the caller is responsible for closing the response body 88 + func (sess *ClientSession) postToAuthServer(ctx context.Context, url string, body interface{}) (*http.Response, error) { 89 + vals, err := query.Values(body) 90 + if err != nil { 91 + return nil, err 91 92 } 92 - tokenURL := sess.Data.AuthServerTokenEndpoint 93 - 94 93 if sess.Config.IsConfidential() { 95 94 clientAssertion, err := sess.Config.NewClientAssertion(sess.Data.AuthServerURL) 96 95 if err != nil { 97 - return "", err 96 + return nil, err 98 97 } 99 - body.ClientAssertionType = &ClientAssertionJWTBearer 100 - body.ClientAssertion = &clientAssertion 101 - } 102 - 103 - vals, err := query.Values(body) 104 - if err != nil { 105 - return "", err 98 + vals.Set("client_assertion_type", ClientAssertionJWTBearer) 99 + vals.Set("client_assertion", clientAssertion) 106 100 } 107 101 bodyBytes := []byte(vals.Encode()) 108 102 109 103 var resp *http.Response 110 104 for range 2 { 111 - dpopJWT, err := NewAuthDPoP("POST", sess.Data.AuthServerTokenEndpoint, sess.Data.DPoPAuthServerNonce, sess.DPoPPrivateKey) 105 + dpopJWT, err := NewAuthDPoP("POST", url, sess.Data.DPoPAuthServerNonce, sess.DPoPPrivateKey) 112 106 if err != nil { 113 - return "", err 107 + return nil, err 114 108 } 115 109 116 - req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, bytes.NewBuffer(bodyBytes)) 110 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(bodyBytes)) 117 111 if err != nil { 118 - return "", err 112 + return nil, err 119 113 } 120 114 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 121 115 req.Header.Set("DPoP", dpopJWT) 122 116 123 117 resp, err = sess.Client.Do(req) 124 118 if err != nil { 125 - return "", err 119 + return nil, err 126 120 } 127 121 128 122 // always check if a new DPoP nonce was provided, and proactively update session data (even if there was not an explicit error) ··· 140 134 // already updated nonce value above; loop around and try again 141 135 continue 142 136 } 143 - return "", fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, reason) 137 + return nil, fmt.Errorf("auth server request failed (HTTP %d): %s", resp.StatusCode, reason) 144 138 } 145 139 146 140 // otherwise process response (success or other error type) 147 141 break 148 142 } 149 143 144 + return resp, nil 145 + } 146 + 147 + // Requests new tokens from auth server, and returns the new access token on success. 148 + // 149 + // Internally takes a lock on session data around the entire refresh process, including retries. Persists data using [PersistSessionCallback] if configured. 150 + func (sess *ClientSession) RefreshTokens(ctx context.Context) (string, error) { 151 + sess.lk.Lock() 152 + defer sess.lk.Unlock() 153 + 154 + body := RefreshTokenRequest{ 155 + ClientID: sess.Config.ClientID, 156 + GrantType: "refresh_token", 157 + RefreshToken: sess.Data.RefreshToken, 158 + } 159 + 160 + resp, err := sess.postToAuthServer(ctx, sess.Data.AuthServerTokenEndpoint, body) 161 + if err != nil { 162 + return "", fmt.Errorf("token refresh failed: %w", err) 163 + } 164 + 150 165 defer resp.Body.Close() 151 166 if resp.StatusCode != http.StatusOK { 152 167 reason := parseAuthErrorReason(resp, "token-refresh") ··· 170 185 } 171 186 172 187 return sess.Data.AccessToken, nil 188 + } 189 + 190 + // If supported by the AS, use the revocation endpoint to revoke both the access token and the refresh token. 191 + // This method always succeeds - any errors during revocation are logged but not returned. 192 + func (sess *ClientSession) RevokeSession(ctx context.Context) error { 193 + sess.lk.Lock() 194 + defer sess.lk.Unlock() 195 + 196 + if sess.Data.AuthServerRevocationEndpoint == "" { 197 + return fmt.Errorf("AS does not support token revocation") 198 + } 199 + 200 + resp, err1 := sess.postToAuthServer(ctx, sess.Data.AuthServerRevocationEndpoint, RevocationRequest{ 201 + ClientID: sess.Config.ClientID, 202 + Token: sess.Data.AccessToken, 203 + TokenTypeHint: "access_token", 204 + }) 205 + if err1 != nil { 206 + err1 = fmt.Errorf("failed revoking access token: %w", err1) 207 + } else { 208 + if resp.StatusCode != http.StatusOK { 209 + err1 = fmt.Errorf("bad HTTP status while revoking access token (%d)", resp.StatusCode) 210 + } 211 + resp.Body.Close() 212 + } 213 + 214 + resp, err2 := sess.postToAuthServer(ctx, sess.Data.AuthServerRevocationEndpoint, RevocationRequest{ 215 + ClientID: sess.Config.ClientID, 216 + Token: sess.Data.RefreshToken, 217 + TokenTypeHint: "refresh_token", 218 + }) 219 + if err2 != nil { 220 + err2 = fmt.Errorf("failed revoking refresh token: %w", err1) 221 + } else { 222 + if resp.StatusCode != 200 { 223 + err2 = fmt.Errorf("bad HTTP status while revoking refresh token (%d)", resp.StatusCode) 224 + } 225 + resp.Body.Close() 226 + } 227 + 228 + return errors.Join(err1, err2) // returns nil if both errors are nil 173 229 } 174 230 175 231 // Constructs and signs a DPoP JWT to include in request header to Host (aka Resource Server, aka PDS). These tokens are different from those used with Auth Server token endpoints (even if the PDS is filling both roles) ··· 341 397 return nil, fmt.Errorf("OAuth client ran out of request retries") 342 398 } 343 399 344 - // Creates a new [client.APIClient] which wraps this session for auth. 345 - func (sess *ClientSession) APIClient() *client.APIClient { 346 - c := client.APIClient{ 400 + // Creates a new [atclient.APIClient] which wraps this session for auth. 401 + func (sess *ClientSession) APIClient() *atclient.APIClient { 402 + c := atclient.APIClient{ 347 403 Client: sess.Client, 348 404 Host: sess.Data.HostURL, 349 405 Auth: sess,
+5 -1
atproto/auth/oauth/store.go
··· 10 10 // 11 11 // This interface supports multiple sessions for a single account (DID). This is helpful for traditional web app backends where a single user might log in and have concurrent sessions from multiple browsers/devices. For situations where multiple sessions are not required, implementations of this interface could ignore the `sessionID` parameters, though this could result in clobbering of active sessions. 12 12 // 13 - // For authorization-only (authn-only) applications, the `SaveSession()` method could be a no-op. 13 + // For authentication-only (authn-only) applications, the `SaveSession()` method could be a no-op. 14 14 // 15 15 // Implementations should generally allow for concurrent access. 16 + // 17 + // `SaveSession()` should be treated as an "upsert" operation (updating a previously saved session with matching did and sessionID, if present). `SaveAuthRequestInfo()` however is create-only. 18 + // 19 + // Implementations are responsible for garbage-collecting expired sessions and auth requests. 16 20 type ClientAuthStore interface { 17 21 GetSession(ctx context.Context, did syntax.DID, sessionID string) (*ClientSessionData, error) 18 22 SaveSession(ctx context.Context, sess ClientSessionData) error
+28 -2
atproto/auth/oauth/types.go
··· 7 7 "slices" 8 8 "strings" 9 9 10 - "github.com/bluesky-social/indigo/atproto/crypto" 10 + "github.com/bluesky-social/indigo/atproto/atcrypto" 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 ) 13 13 ··· 19 19 ) 20 20 21 21 type JWKS struct { 22 - Keys []crypto.JWK `json:"keys"` 22 + Keys []atcrypto.JWK `json:"keys"` 23 23 } 24 24 25 25 // Expected response type from looking up OAuth Protected Resource information on a server (eg, a PDS instance) ··· 197 197 198 198 // must be true 199 199 ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` 200 + 201 + // optional, used to explicitly revoke access/refresh tokens on logout, if present 202 + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` 200 203 } 201 204 202 205 func (m *AuthServerMetadata) Validate(serverURL string) error { ··· 339 342 // Full token endpoint URL 340 343 AuthServerTokenEndpoint string `json:"authserver_token_endpoint"` 341 344 345 + // Full revocation endpoint, if it exists 346 + AuthServerRevocationEndpoint string `json:"authserver_revocation_endpoint,omitempty"` 347 + 342 348 // The secret token/nonce which a code challenge was generated from 343 349 PKCEVerifier string `json:"pkce_verifier"` 344 350 ··· 406 412 407 413 // Refresh token, for doing additional token requests to the auth server. 408 414 RefreshToken string `json:"refresh_token"` 415 + } 416 + 417 + // The fields which are included in a token revocation request. These HTTP POST bodies are form-encoded, so use URL encoding syntax, not JSON. 418 + // 419 + // Per https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 420 + type RevocationRequest struct { 421 + // Client ID, aka client metadata URL 422 + ClientID string `url:"client_id"` 423 + 424 + // The token to revoke 425 + Token string `url:"token"` 426 + 427 + // Either "access_token" or "refresh_token" 428 + TokenTypeHint string `url:"token_type_hint"` 429 + 430 + // For confidential clients, must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 431 + ClientAssertionType *string `url:"client_assertion_type"` 432 + 433 + // For confidential clients, the signed client assertion JWT 434 + ClientAssertion *string `url:"client_assertion"` 409 435 } 410 436 411 437 // Returned by [ClientApp.ProcessCallback] if the AS signals an error in the redirect URL parameters, per rfc6749 section 4.1.2.1
+1 -1
atproto/client/admin_auth.go atproto/atclient/admin_auth.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "net/http"
+1 -1
atproto/client/admin_auth_test.go atproto/atclient/admin_auth_test.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "context"
+1 -1
atproto/client/apiclient.go atproto/atclient/apiclient.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "bytes"
+1 -1
atproto/client/apiclient_test.go atproto/atclient/apiclient_test.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "testing"
+1 -1
atproto/client/apierror.go atproto/atclient/apierror.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "fmt"
+1 -1
atproto/client/apirequest.go atproto/atclient/apirequest.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "context"
+8 -8
atproto/client/cmd/atp-client-demo/main.go atproto/atclient/cmd/atp-client-demo/main.go
··· 8 8 "os" 9 9 10 10 comatproto "github.com/bluesky-social/indigo/api/agnostic" 11 - "github.com/bluesky-social/indigo/atproto/client" 11 + "github.com/bluesky-social/indigo/atproto/atclient" 12 12 "github.com/bluesky-social/indigo/atproto/identity" 13 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 14 ··· 120 120 app.RunAndExitOnError() 121 121 } 122 122 123 - func getFeed(ctx context.Context, c *client.APIClient) error { 123 + func getFeed(ctx context.Context, c *atclient.APIClient) error { 124 124 params := map[string]any{ 125 125 "actor": "atproto.com", 126 126 "limit": 2, ··· 141 141 return nil 142 142 } 143 143 144 - func listRecords(ctx context.Context, c *client.APIClient) error { 144 + func listRecords(ctx context.Context, c *atclient.APIClient) error { 145 145 146 146 list, err := comatproto.RepoListRecords(ctx, c, "app.bsky.actor.profile", "", 10, "did:plc:ewvi7nxzyoun6zhxrhs64oiz", false) 147 147 if err != nil { ··· 159 159 func runGetFeedPublic(cctx *cli.Context) error { 160 160 ctx := cctx.Context 161 161 162 - c := client.APIClient{ 162 + c := atclient.APIClient{ 163 163 Host: cctx.String("host"), 164 164 } 165 165 ··· 169 169 func runListRecordsPublic(cctx *cli.Context) error { 170 170 ctx := cctx.Context 171 171 172 - c := client.APIClient{ 172 + c := atclient.APIClient{ 173 173 Host: cctx.String("host"), 174 174 } 175 175 ··· 186 186 187 187 dir := identity.DefaultDirectory() 188 188 189 - c, err := client.LoginWithPassword(ctx, dir, *atid, cctx.String("password"), "", nil) 189 + c, err := atclient.LoginWithPassword(ctx, dir, *atid, cctx.String("password"), "", nil) 190 190 if err != nil { 191 191 return err 192 192 } ··· 215 215 216 216 dir := identity.DefaultDirectory() 217 217 218 - c, err := client.LoginWithPassword(ctx, dir, *atid, cctx.String("password"), "", nil) 218 + c, err := atclient.LoginWithPassword(ctx, dir, *atid, cctx.String("password"), "", nil) 219 219 if err != nil { 220 220 return err 221 221 } ··· 227 227 func runLookupAdmin(cctx *cli.Context) error { 228 228 ctx := cctx.Context 229 229 230 - c := client.NewAdminClient(cctx.String("host"), cctx.String("admin-password")) 230 + c := atclient.NewAdminClient(cctx.String("host"), cctx.String("admin-password")) 231 231 232 232 var d json.RawMessage 233 233 params := map[string]any{
+1 -1
atproto/client/doc.go atproto/atclient/doc.go
··· 18 18 19 19 This package tries to use minimal dependencies beyond the Go standard library, to make it easy to reference as a dependency. It does require the [github.com/bluesky-social/indigo/atproto/syntax] and [github.com/bluesky-social/indigo/atproto/identity] sibling packages. In particular, this package does not include any auth methods requiring JWTs, to avoid adding any specific JWT implementation as a dependency. 20 20 */ 21 - package client 21 + package atclient
+1 -1
atproto/client/examples_test.go atproto/atclient/examples_test.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "context"
+1 -1
atproto/client/lexclient.go atproto/atclient/lexclient.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "bytes"
+1 -1
atproto/client/params.go atproto/atclient/params.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "encoding"
+1 -1
atproto/client/params_test.go atproto/atclient/params_test.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "net/url"
+1 -1
atproto/client/password_auth.go atproto/atclient/password_auth.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "context"
+1 -1
atproto/client/password_auth_test.go atproto/atclient/password_auth_test.go
··· 1 - package client 1 + package atclient 2 2 3 3 import ( 4 4 "bytes"
atproto/client/testdata/body.json atproto/atclient/testdata/body.json
+3 -3
atproto/crypto/cmd/atp-crypto/main.go atproto/atcrypto/cmd/atp-crypto/main.go
··· 5 5 "log/slog" 6 6 "os" 7 7 8 - "github.com/bluesky-social/indigo/atproto/crypto" 8 + "github.com/bluesky-social/indigo/atproto/atcrypto" 9 9 10 10 "github.com/urfave/cli/v2" 11 11 ) ··· 40 40 41 41 func runGenerate(cctx *cli.Context) error { 42 42 if cctx.Bool("k256") { 43 - priv, err := crypto.GeneratePrivateKeyK256() 43 + priv, err := atcrypto.GeneratePrivateKeyK256() 44 44 if err != nil { 45 45 return err 46 46 } 47 47 fmt.Println(priv.Multibase()) 48 48 } else { 49 - priv, err := crypto.GeneratePrivateKeyP256() 49 + priv, err := atcrypto.GeneratePrivateKeyP256() 50 50 if err != nil { 51 51 return err 52 52 }
+2 -2
atproto/crypto/docs.go atproto/atcrypto/docs.go
··· 1 - // Package crypto provides cryptographic keys and operations, as used in atproto (the protocol) 1 + // Package atcrypto provides cryptographic keys and operations, as used in atproto (the protocol) 2 2 // 3 3 // This package attempts to abstract away the specific curves, compressions, signature variations, and other implementation details. The goal is to provide as few knobs and options as possible when working with this library. Use of cryptography in atproto is specified in https://atproto.com/specs/cryptography. 4 4 // ··· 8 8 // - K-256/secp256r1, internally implemented using https://gitlab.com/yawning/secp256k1-voi 9 9 // 10 10 // "Low-S" signatures are enforced for both key types, both when creating signatures and during verification, as required by the atproto specification. 11 - package crypto 11 + package atcrypto
+1 -1
atproto/crypto/examples_test.go atproto/atcrypto/examples_test.go
··· 1 - package crypto 1 + package atcrypto 2 2 3 3 import ( 4 4 "encoding/base64"
+1 -1
atproto/crypto/interop_fixtures_test.go atproto/atcrypto/interop_fixtures_test.go
··· 1 - package crypto 1 + package atcrypto 2 2 3 3 import ( 4 4 "encoding/base64"
+1 -1
atproto/crypto/jwk.go atproto/atcrypto/jwk.go
··· 1 - package crypto 1 + package atcrypto 2 2 3 3 import ( 4 4 "crypto/ecdsa"
+1 -1
atproto/crypto/jwk_test.go atproto/atcrypto/jwk_test.go
··· 1 - package crypto 1 + package atcrypto 2 2 3 3 import ( 4 4 "testing"
+1 -1
atproto/crypto/k256.go atproto/atcrypto/k256.go
··· 1 - package crypto 1 + package atcrypto 2 2 3 3 import ( 4 4 "crypto"
+1 -1
atproto/crypto/keys.go atproto/atcrypto/keys.go
··· 1 - package crypto 1 + package atcrypto 2 2 3 3 import ( 4 4 "errors"
+1 -1
atproto/crypto/keys_test.go atproto/atcrypto/keys_test.go
··· 1 - package crypto 1 + package atcrypto 2 2 3 3 import ( 4 4 "crypto/rand"
+1 -1
atproto/crypto/p256.go atproto/atcrypto/p256.go
··· 1 - package crypto 1 + package atcrypto 2 2 3 3 import ( 4 4 "crypto/ecdh"
+1 -1
atproto/crypto/p256_lowS.go atproto/atcrypto/p256_lowS.go
··· 1 - package crypto 1 + package atcrypto 2 2 3 3 import ( 4 4 "crypto/elliptic"
atproto/crypto/testdata/signature-fixtures.json atproto/atcrypto/testdata/signature-fixtures.json
atproto/crypto/testdata/w3c_didkey_K256.json atproto/atcrypto/testdata/w3c_didkey_K256.json
atproto/crypto/testdata/w3c_didkey_P256.json atproto/atcrypto/testdata/w3c_didkey_P256.json
+1 -1
atproto/crypto/w3c_didkey_test.go atproto/atcrypto/w3c_didkey_test.go
··· 1 - package crypto 1 + package atcrypto 2 2 3 3 import ( 4 4 "encoding/hex"
+1 -1
atproto/data/basic_test.go atproto/atdata/basic_test.go
··· 1 - package data 1 + package atdata 2 2 3 3 import ( 4 4 "encoding/json"
+1 -1
atproto/data/blob.go atproto/atdata/blob.go
··· 1 - package data 1 + package atdata 2 2 3 3 import ( 4 4 "bytes"
+1 -1
atproto/data/bytes.go atproto/atdata/bytes.go
··· 1 - package data 1 + package atdata 2 2 3 3 import ( 4 4 "encoding/base64"
+3 -3
atproto/data/cbor_gen.go atproto/atdata/cbor_gen.go
··· 1 1 // Code generated by github.com/whyrusleeping/cbor-gen. DO NOT EDIT. 2 2 3 - package data 3 + package atdata 4 4 5 5 import ( 6 6 "fmt" ··· 264 264 return err 265 265 } 266 266 267 - // t.Ref (data.CIDLink) (struct) 267 + // t.Ref (atdata.CIDLink) (struct) 268 268 if len("ref") > 1000000 { 269 269 return xerrors.Errorf("Value in field \"ref\" was too long") 270 270 } ··· 387 387 } 388 388 389 389 switch string(nameBuf[:nameLen]) { 390 - // t.Ref (data.CIDLink) (struct) 390 + // t.Ref (atdata.CIDLink) (struct) 391 391 case "ref": 392 392 393 393 {
+1 -1
atproto/data/cidlink.go atproto/atdata/cidlink.go
··· 1 - package data 1 + package atdata 2 2 3 3 import ( 4 4 "encoding/json"
+1 -1
atproto/data/const.go atproto/atdata/const.go
··· 1 - package data 1 + package atdata 2 2 3 3 const ( 4 4 // maximum size of any CBOR data, in any context, in atproto
+1 -1
atproto/data/data.go atproto/atdata/data.go
··· 1 - package data 1 + package atdata 2 2 3 3 import ( 4 4 "encoding/json"
+2 -2
atproto/data/doc.go atproto/atdata/doc.go
··· 1 1 /* 2 - Package data supports schema-less serializaiton and deserialization of atproto data 2 + Package atdata supports schema-less serializaiton and deserialization of atproto data 3 3 4 4 Some restrictions from the data model include: 5 5 - string sizes ··· 15 15 16 16 Has a helper for serializing generic data (map[string]interface{}) to CBOR, which handles converting JSON-style object types (like $link and $bytes) as needed. There is no "MarshalJSON" method; simply use the standard library's `encoding/json`. 17 17 */ 18 - package data 18 + package atdata
+1 -1
atproto/data/extract.go atproto/atdata/extract.go
··· 1 - package data 1 + package atdata 2 2 3 3 import ( 4 4 "bytes"
+1 -1
atproto/data/extract_test.go atproto/atdata/extract_test.go
··· 1 - package data 1 + package atdata 2 2 3 3 import ( 4 4 "io"
+1 -1
atproto/data/interop_test.go atproto/atdata/interop_test.go
··· 1 - package data 1 + package atdata 2 2 3 3 import ( 4 4 "encoding/base64"
+1 -1
atproto/data/parse.go atproto/atdata/parse.go
··· 1 - package data 1 + package atdata 2 2 3 3 import ( 4 4 "encoding"
atproto/data/testdata/data-model-fixtures.json atproto/atdata/testdata/data-model-fixtures.json
atproto/data/testdata/data-model-invalid.json atproto/atdata/testdata/data-model-invalid.json
atproto/data/testdata/data-model-valid.json atproto/atdata/testdata/data-model-valid.json
atproto/data/testdata/feedpost_record.cbor atproto/atdata/testdata/feedpost_record.cbor
+8 -8
atproto/identity/identity.go
··· 5 5 "net/url" 6 6 "strings" 7 7 8 - "github.com/bluesky-social/indigo/atproto/crypto" 8 + "github.com/bluesky-social/indigo/atproto/atcrypto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 11 11 "github.com/mr-tron/base58" ··· 119 119 // 120 120 // Returns [ErrKeyNotFound] if there is no such key. 121 121 // 122 - // Note that [crypto.PublicKey] is an interface, not a concrete type. 123 - func (i *Identity) PublicKey() (crypto.PublicKey, error) { 122 + // Note that [atcrypto.PublicKey] is an interface, not a concrete type. 123 + func (i *Identity) PublicKey() (atcrypto.PublicKey, error) { 124 124 return i.GetPublicKey("atproto") 125 125 } 126 126 ··· 128 128 // 129 129 // Returns [ErrKeyNotFound] if there is no such key. 130 130 // 131 - // Note that [crypto.PublicKey] is an interface, not a concrete type. 132 - func (i *Identity) GetPublicKey(id string) (crypto.PublicKey, error) { 131 + // Note that [atcrypto.PublicKey] is an interface, not a concrete type. 132 + func (i *Identity) GetPublicKey(id string) (atcrypto.PublicKey, error) { 133 133 if i.Keys == nil { 134 134 return nil, ErrKeyNotDeclared 135 135 } ··· 139 139 } 140 140 switch k.Type { 141 141 case "Multikey": 142 - return crypto.ParsePublicMultibase(k.PublicKeyMultibase) 142 + return atcrypto.ParsePublicMultibase(k.PublicKeyMultibase) 143 143 case "EcdsaSecp256r1VerificationKey2019": 144 144 if len(k.PublicKeyMultibase) < 2 || k.PublicKeyMultibase[0] != 'z' { 145 145 return nil, fmt.Errorf("identity key not a multibase base58btc string") ··· 148 148 if err != nil { 149 149 return nil, fmt.Errorf("identity key multibase parsing: %w", err) 150 150 } 151 - return crypto.ParsePublicUncompressedBytesP256(keyBytes) 151 + return atcrypto.ParsePublicUncompressedBytesP256(keyBytes) 152 152 case "EcdsaSecp256k1VerificationKey2019": 153 153 if len(k.PublicKeyMultibase) < 2 || k.PublicKeyMultibase[0] != 'z' { 154 154 return nil, fmt.Errorf("identity key not a multibase base58btc string") ··· 157 157 if err != nil { 158 158 return nil, fmt.Errorf("identity key multibase parsing: %w", err) 159 159 } 160 - return crypto.ParsePublicUncompressedBytesK256(keyBytes) 160 + return atcrypto.ParsePublicUncompressedBytesK256(keyBytes) 161 161 default: 162 162 return nil, fmt.Errorf("unsupported atproto public key type: %s", k.Type) 163 163 }
+3 -3
atproto/label/cbor_gen.go atproto/labeling/cbor_gen.go
··· 1 1 // Code generated by github.com/whyrusleeping/cbor-gen. DO NOT EDIT. 2 2 3 - package label 3 + package labeling 4 4 5 5 import ( 6 6 "fmt" ··· 159 159 } 160 160 } 161 161 162 - // t.Sig (data.Bytes) (slice) 162 + // t.Sig (atdata.Bytes) (slice) 163 163 if t.Sig != nil { 164 164 165 165 if len("sig") > 1000000 { ··· 408 408 t.Negated = &val 409 409 } 410 410 } 411 - // t.Sig (data.Bytes) (slice) 411 + // t.Sig (atdata.Bytes) (slice) 412 412 case "sig": 413 413 414 414 maj, extra, err = cr.ReadHeader()
+15 -15
atproto/label/label.go atproto/labeling/label.go
··· 1 - package label 1 + package labeling 2 2 3 3 import ( 4 4 "bytes" 5 5 "fmt" 6 6 7 7 comatproto "github.com/bluesky-social/indigo/api/atproto" 8 - "github.com/bluesky-social/indigo/atproto/crypto" 9 - "github.com/bluesky-social/indigo/atproto/data" 8 + "github.com/bluesky-social/indigo/atproto/atcrypto" 9 + "github.com/bluesky-social/indigo/atproto/atdata" 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 ) 12 12 ··· 14 14 const ATPROTO_LABEL_VERSION int64 = 1 15 15 16 16 type Label struct { 17 - CID *string `json:"cid,omitempty" cborgen:"cid,omitempty"` 18 - CreatedAt string `json:"cts" cborgen:"cts"` 19 - ExpiresAt *string `json:"exp,omitempty" cborgen:"exp,omitempty"` 20 - Negated *bool `json:"neg,omitempty" cborgen:"neg,omitempty"` 21 - SourceDID string `json:"src" cborgen:"src"` 22 - URI string `json:"uri" cborgen:"uri"` 23 - Val string `json:"val" cborgen:"val"` 24 - Version int64 `json:"ver" cborgen:"ver"` 25 - Sig data.Bytes `json:"sig,omitempty" cborgen:"sig,omitempty"` 17 + CID *string `json:"cid,omitempty" cborgen:"cid,omitempty"` 18 + CreatedAt string `json:"cts" cborgen:"cts"` 19 + ExpiresAt *string `json:"exp,omitempty" cborgen:"exp,omitempty"` 20 + Negated *bool `json:"neg,omitempty" cborgen:"neg,omitempty"` 21 + SourceDID string `json:"src" cborgen:"src"` 22 + URI string `json:"uri" cborgen:"uri"` 23 + Val string `json:"val" cborgen:"val"` 24 + Version int64 `json:"ver" cborgen:"ver"` 25 + Sig atdata.Bytes `json:"sig,omitempty" cborgen:"sig,omitempty"` 26 26 } 27 27 28 28 // converts to map[string]any for printing as JSON ··· 45 45 d["neg"] = l.Negated 46 46 } 47 47 if l.Sig != nil { 48 - d["sig"] = data.Bytes(l.Sig) 48 + d["sig"] = atdata.Bytes(l.Sig) 49 49 } 50 50 return d 51 51 } ··· 110 110 } 111 111 112 112 // Signs the commit, storing the signature in the `Sig` field 113 - func (l *Label) Sign(privkey crypto.PrivateKey) error { 113 + func (l *Label) Sign(privkey atcrypto.PrivateKey) error { 114 114 b, err := l.UnsignedBytes() 115 115 if err != nil { 116 116 return err ··· 124 124 } 125 125 126 126 // Verifies `Sig` field using the provided key. Returns `nil` if signature is valid. 127 - func (l *Label) VerifySignature(pubkey crypto.PublicKey) error { 127 + func (l *Label) VerifySignature(pubkey atcrypto.PublicKey) error { 128 128 if l.Sig == nil { 129 129 return fmt.Errorf("can not verify unsigned commit") 130 130 }
+4 -4
atproto/label/label_test.go atproto/labeling/label_test.go
··· 1 - package label 1 + package labeling 2 2 3 3 import ( 4 4 "encoding/json" 5 5 "testing" 6 6 7 7 comatproto "github.com/bluesky-social/indigo/api/atproto" 8 - "github.com/bluesky-social/indigo/atproto/crypto" 8 + "github.com/bluesky-social/indigo/atproto/atcrypto" 9 9 10 10 "github.com/stretchr/testify/assert" 11 11 ) ··· 14 14 assert := assert.New(t) 15 15 16 16 pubkeyStr := "zQ3shcnfWLQN1bY4d2patsEAYFzy4xp1zdckEvHsV7S4ocTnC" 17 - pubkey, err := crypto.ParsePublicMultibase(pubkeyStr) 17 + pubkey, err := atcrypto.ParsePublicMultibase(pubkeyStr) 18 18 if err != nil { 19 19 t.Fatal(err) 20 20 } ··· 77 77 SourceDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 78 78 } 79 79 80 - priv, err := crypto.GeneratePrivateKeyK256() 80 + priv, err := atcrypto.GeneratePrivateKeyK256() 81 81 if err != nil { 82 82 t.Fatal(err) 83 83 }
+7 -21
atproto/lexicon/catalog.go
··· 50 50 51 51 // Inserts a schema loaded from a JSON file in to the catalog. 52 52 func (c *BaseCatalog) AddSchemaFile(sf SchemaFile) error { 53 - if sf.Lexicon != 1 { 54 - return fmt.Errorf("unsupported lexicon language version: %d", sf.Lexicon) 53 + 54 + if err := sf.CheckSchema(); err != nil { 55 + return err 55 56 } 57 + 56 58 base := sf.ID 57 59 for frag, def := range sf.Defs { 58 - if len(frag) == 0 || strings.Contains(frag, "#") || strings.Contains(frag, ".") { 59 - // TODO: more validation here? 60 - return fmt.Errorf("schema name invalid: %s", frag) 61 - } 62 60 name := base + "#" + frag 63 61 if _, ok := c.schemas[name]; ok { 64 62 return fmt.Errorf("catalog already contained a schema with name: %s", name) 65 63 } 66 - // "A file can have at most one definition with one of the "primary" types. Primary types should always have the name main. It is possible for main to describe a non-primary type." 67 - switch s := def.Inner.(type) { 68 - case SchemaRecord, SchemaQuery, SchemaProcedure, SchemaSubscription, SchemaPermissionSet: 69 - if frag != "main" { 70 - return fmt.Errorf("record, query, procedure, and subscription types must be 'main', not: %s", frag) 71 - } 72 - case SchemaToken: 73 - // add fully-qualified name to token 74 - s.fullName = name 75 - def.Inner = s 76 - } 77 - def.SetBase(base) 78 - if err := def.CheckSchema(); err != nil { 79 - return err 80 - } 81 64 s := Schema{ 82 65 ID: name, 83 66 Def: def.Inner, ··· 91 74 func (c *BaseCatalog) addSchemaFromBytes(b []byte) error { 92 75 var sf SchemaFile 93 76 if err := json.Unmarshal(b, &sf); err != nil { 77 + return err 78 + } 79 + if err := sf.FinishParse(); err != nil { 94 80 return err 95 81 } 96 82 if err := c.AddSchemaFile(sf); err != nil {
+2 -2
atproto/lexicon/cmd/lextool/net.go
··· 7 7 "log/slog" 8 8 "net/http" 9 9 10 - "github.com/bluesky-social/indigo/atproto/data" 10 + "github.com/bluesky-social/indigo/atproto/atdata" 11 11 "github.com/bluesky-social/indigo/atproto/identity" 12 12 "github.com/bluesky-social/indigo/atproto/lexicon" 13 13 "github.com/bluesky-social/indigo/atproto/syntax" ··· 64 64 return err 65 65 } 66 66 67 - body, err := data.UnmarshalJSON(respBytes) 67 + body, err := atdata.UnmarshalJSON(respBytes) 68 68 if err != nil { 69 69 return err 70 70 }
+2 -2
atproto/lexicon/examples_test.go
··· 3 3 import ( 4 4 "fmt" 5 5 6 - atdata "github.com/bluesky-social/indigo/atproto/data" 6 + "github.com/bluesky-social/indigo/atproto/atdata" 7 7 ) 8 8 9 9 func ExampleValidateRecord() { ··· 14 14 panic("failed to load lexicons") 15 15 } 16 16 17 - // Parse record JSON data using atproto/data helper 17 + // Parse record JSON data using atdata helper 18 18 recordJSON := `{ 19 19 "$type": "example.lexicon.record", 20 20 "integer": 123,
+3 -3
atproto/lexicon/interop_record_test.go
··· 7 7 "os" 8 8 "testing" 9 9 10 - "github.com/bluesky-social/indigo/atproto/data" 10 + "github.com/bluesky-social/indigo/atproto/atdata" 11 11 12 12 "github.com/stretchr/testify/assert" 13 13 ) ··· 44 44 45 45 for _, fixture := range fixtures { 46 46 fmt.Println(fixture.Name) 47 - d, err := data.UnmarshalJSON(fixture.Data) 47 + d, err := atdata.UnmarshalJSON(fixture.Data) 48 48 if err != nil { 49 49 t.Fatal(err) 50 50 } ··· 79 79 80 80 for _, fixture := range fixtures { 81 81 fmt.Println(fixture.Name) 82 - d, err := data.UnmarshalJSON(fixture.Data) 82 + d, err := atdata.UnmarshalJSON(fixture.Data) 83 83 if err != nil { 84 84 t.Fatal(err) 85 85 }
+38 -31
atproto/lexicon/language.go
··· 6 6 "reflect" 7 7 "strings" 8 8 9 - "github.com/bluesky-social/indigo/atproto/data" 9 + "github.com/bluesky-social/indigo/atproto/atdata" 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 12 12 "github.com/rivo/uniseg" 13 13 ) 14 - 15 - // Serialization helper type for top-level Lexicon schema JSON objects (files) 16 - type SchemaFile struct { 17 - Lexicon int `json:"lexicon"` // must be 1 18 - ID string `json:"id"` 19 - Description *string `json:"description,omitempty"` 20 - Defs map[string]SchemaDef `json:"defs"` 21 - } 22 14 23 15 // enum type to represent any of the schema fields 24 16 type SchemaDef struct { ··· 74 66 } 75 67 76 68 // Helper to recurse down the definition tree and set full references on any sub-schemas which need to embed that metadata 77 - func (s *SchemaDef) SetBase(base string) { 69 + func (s *SchemaDef) setBase(base string) { 78 70 switch v := s.Inner.(type) { 79 71 case SchemaRecord: 80 72 for i, val := range v.Record.Properties { 81 - val.SetBase(base) 73 + val.setBase(base) 82 74 v.Record.Properties[i] = val 83 75 } 84 76 s.Inner = v 85 77 case SchemaQuery: 86 78 if v.Parameters != nil { 87 79 for i, val := range v.Parameters.Properties { 88 - val.SetBase(base) 80 + val.setBase(base) 89 81 v.Parameters.Properties[i] = val 90 82 } 91 83 } 92 84 if v.Output != nil && v.Output.Schema != nil { 93 - v.Output.Schema.SetBase(base) 85 + v.Output.Schema.setBase(base) 94 86 } 95 87 s.Inner = v 96 88 case SchemaProcedure: 97 89 if v.Parameters != nil { 98 90 for i, val := range v.Parameters.Properties { 99 - val.SetBase(base) 91 + val.setBase(base) 100 92 v.Parameters.Properties[i] = val 101 93 } 102 94 } 103 95 if v.Input != nil && v.Input.Schema != nil { 104 - v.Input.Schema.SetBase(base) 96 + v.Input.Schema.setBase(base) 105 97 } 106 98 if v.Output != nil && v.Output.Schema != nil { 107 - v.Output.Schema.SetBase(base) 99 + v.Output.Schema.setBase(base) 108 100 } 109 101 s.Inner = v 110 102 case SchemaSubscription: 111 103 if v.Parameters != nil { 112 104 for i, val := range v.Parameters.Properties { 113 - val.SetBase(base) 105 + val.setBase(base) 114 106 v.Parameters.Properties[i] = val 115 107 } 116 108 } 117 109 if v.Message != nil { 118 - v.Message.Schema.SetBase(base) 110 + v.Message.Schema.setBase(base) 119 111 } 120 112 s.Inner = v 121 113 case SchemaArray: 122 - v.Items.SetBase(base) 114 + v.Items.setBase(base) 123 115 s.Inner = v 124 116 case SchemaObject: 125 117 for i, val := range v.Properties { 126 - val.SetBase(base) 118 + val.setBase(base) 127 119 v.Properties[i] = val 128 120 } 129 121 s.Inner = v 130 122 case SchemaParams: 131 123 for i, val := range v.Properties { 132 - val.SetBase(base) 124 + val.setBase(base) 133 125 v.Properties[i] = val 134 126 } 135 127 s.Inner = v ··· 412 404 413 405 type SchemaPermissionSet struct { 414 406 Type string `json:"type"` // "permission-set" 415 - Description *string `json:"description,omitempty"` 407 + Title *string `json:"title,omitempty"` 408 + TitleLangs map[string]string `json:"title:langs,omitempty"` 409 + Detail *string `json:"detail,omitempty"` 410 + DetailLangs map[string]string `json:"detail:langs,omitempty"` 416 411 Permissions []SchemaPermission `json:"permissions"` 417 412 } 418 413 419 414 func (s *SchemaPermissionSet) CheckSchema() error { 415 + for lang, _ := range s.TitleLangs { 416 + _, err := syntax.ParseLanguage(lang) 417 + if err != nil { 418 + return err 419 + } 420 + } 421 + for lang, _ := range s.DetailLangs { 422 + _, err := syntax.ParseLanguage(lang) 423 + if err != nil { 424 + return err 425 + } 426 + } 420 427 for _, p := range s.Permissions { 421 428 if err := p.CheckSchema(); err != nil { 422 429 return err ··· 492 499 if (s.InheritAud == true && s.Audience != "") || (s.InheritAud == false && s.Audience == "") { 493 500 return fmt.Errorf("rpc permission must have eith 'aud' or 'inheritAud' defined") 494 501 } 495 - if s.Audience != "" { 502 + if s.Audience != "" && s.Audience != "*" { 496 503 // TODO: helper for service refs 497 504 parts := strings.SplitN(s.Audience, "#", 3) 498 505 if len(parts) != 2 || parts[1] == "" { ··· 803 810 } 804 811 805 812 func (s *SchemaBytes) Validate(d any) error { 806 - v, ok := d.(data.Bytes) 813 + v, ok := d.(atdata.Bytes) 807 814 if !ok { 808 815 return fmt.Errorf("expecting bytes") 809 816 } ··· 823 830 } 824 831 825 832 func (s *SchemaCIDLink) Validate(d any) error { 826 - _, ok := d.(data.CIDLink) 833 + _, ok := d.(atdata.CIDLink) 827 834 if !ok { 828 835 return fmt.Errorf("expecting a cid-link") 829 836 } ··· 908 915 } 909 916 910 917 func (s *SchemaBlob) Validate(d any, flags ValidateFlags) error { 911 - v, ok := d.(data.Blob) 918 + v, ok := d.(atdata.Blob) 912 919 if !ok { 913 920 return fmt.Errorf("expected a blob") 914 921 } ··· 978 985 type SchemaToken struct { 979 986 Type string `json:"type"` // "token" 980 987 Description *string `json:"description,omitempty"` 981 - // the fully-qualified identifier of this token 982 - fullName string 988 + // the fully-qualified identifier of this token. this is not included in the schema file; it must be added when parsing 989 + FullName string `json:"-"` 983 990 } 984 991 985 992 func (s *SchemaToken) CheckSchema() error { 986 - if s.fullName == "" { 993 + if s.FullName == "" { 987 994 return fmt.Errorf("expected fully-qualified token name") 988 995 } 989 996 return nil ··· 994 1001 if !ok { 995 1002 return fmt.Errorf("expected a string for token, got: %s", reflect.TypeOf(d)) 996 1003 } 997 - if s.fullName == "" { 1004 + if s.FullName == "" { 998 1005 return fmt.Errorf("token name was not populated at parse time") 999 1006 } 1000 - if str != s.fullName { 1007 + if str != s.FullName { 1001 1008 return fmt.Errorf("token name did not match expected: %s", str) 1002 1009 } 1003 1010 return nil
+2 -2
atproto/lexicon/resolve.go
··· 7 7 "log/slog" 8 8 9 9 "github.com/bluesky-social/indigo/api/agnostic" 10 - "github.com/bluesky-social/indigo/atproto/data" 10 + "github.com/bluesky-social/indigo/atproto/atdata" 11 11 "github.com/bluesky-social/indigo/atproto/identity" 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 "github.com/bluesky-social/indigo/xrpc" ··· 25 25 return nil, err 26 26 } 27 27 28 - d, err := data.UnmarshalJSON(*record) 28 + d, err := atdata.UnmarshalJSON(*record) 29 29 if err != nil { 30 30 return nil, fmt.Errorf("fetched Lexicon schema record was invalid: %w", err) 31 31 }
+69
atproto/lexicon/schemafile.go
··· 1 + package lexicon 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + ) 7 + 8 + // Serialization helper type for top-level Lexicon schema JSON objects (files). 9 + // 10 + // Note that the [FinishParse] method should always be called after unmarshalling a SchemaFile from JSON. 11 + type SchemaFile struct { 12 + Type string `json:"$type,omitempty"` // com.atproto.lexicon.schema (if a record) 13 + Lexicon int `json:"lexicon"` // must be 1 14 + ID string `json:"id"` 15 + Description *string `json:"description,omitempty"` 16 + Defs map[string]SchemaDef `json:"defs"` 17 + } 18 + 19 + // Helper method which should always be called after parsing a schema file (eg, from JSON). 20 + // 21 + // Does some very basic validation (eg, lexicon language version), and fills in 22 + // internal references (for example full name of tokens). 23 + func (sf *SchemaFile) FinishParse() error { 24 + if sf.Lexicon != 1 { 25 + return fmt.Errorf("unsupported lexicon language version: %d", sf.Lexicon) 26 + } 27 + base := sf.ID 28 + for frag, def := range sf.Defs { 29 + if len(frag) == 0 || strings.Contains(frag, "#") || strings.Contains(frag, ".") { 30 + // TODO: more validation here? 31 + return fmt.Errorf("schema name invalid: %s", frag) 32 + } 33 + name := base + "#" + frag 34 + switch s := def.Inner.(type) { 35 + case SchemaToken: 36 + // add fully-qualified name to token 37 + s.FullName = name 38 + def.Inner = s 39 + } 40 + def.setBase(base) 41 + sf.Defs[frag] = def 42 + } 43 + return nil 44 + } 45 + 46 + // Calls [SchemaDef.CheckSchema] recursively over all defs 47 + func (sf *SchemaFile) CheckSchema() error { 48 + if sf.Lexicon != 1 { 49 + return fmt.Errorf("unsupported lexicon language version: %d", sf.Lexicon) 50 + } 51 + 52 + for frag, def := range sf.Defs { 53 + if len(frag) == 0 || strings.Contains(frag, "#") || strings.Contains(frag, ".") { 54 + // TODO: more validation here? 55 + return fmt.Errorf("schema name invalid: %s", frag) 56 + } 57 + // "A file can have at most one definition with one of the "primary" types. Primary types should always have the name main. It is possible for main to describe a non-primary type." 58 + switch def.Inner.(type) { 59 + case SchemaRecord, SchemaQuery, SchemaProcedure, SchemaSubscription, SchemaPermissionSet: 60 + if frag != "main" { 61 + return fmt.Errorf("record, query, procedure, and subscription types must be 'main', not: %s", frag) 62 + } 63 + } 64 + if err := def.CheckSchema(); err != nil { 65 + return err 66 + } 67 + } 68 + return nil 69 + }
+1 -2
atproto/lexicon/testdata/catalog/minimal-query.json
··· 1 1 { 2 2 "lexicon": 1, 3 3 "id": "example.lexicon.minimal.query", 4 - "revision": 1, 5 - "description": "exercizes many lexicon features for the query type", 4 + "description": "exercises many lexicon features for the query type", 6 5 "defs": { 7 6 "main": { 8 7 "type": "query",
+43 -6
atproto/lexicon/testdata/catalog/permission-set.json
··· 1 1 { 2 2 "lexicon": 1, 3 3 "id": "example.lexicon.permissionset", 4 - "description": "exercizes many lexicon features for the permission-set type", 4 + "description": "exercises many lexicon features for the permission-set type", 5 5 "defs": { 6 6 "main": { 7 7 "type": "permission-set", 8 + "title": "Example for Moderation", 9 + "title:lang": { 10 + "fr": "Example for Modération" 11 + }, 12 + "detail": "Create moderation reports", 13 + "detail:lang": { 14 + "fr-FR": "Créer des rapports de modération" 15 + }, 8 16 "permissions": [ 9 17 { 10 18 "type": "permission", 11 19 "resource": "blob", 12 20 "accept": [ 13 - "image/*" 21 + "image/*", 22 + "video/*" 14 23 ] 15 24 }, 16 25 { ··· 39 48 ] 40 49 }, 41 50 { 42 - "type": "permission", 43 - "resource": "rpc", 44 - "aud": "did:web:example.com#foo", 45 - "lxm": ["com.example.calendar.listEvents"] 51 + "type": "permission", 52 + "resource": "repo", 53 + "collection": [ 54 + "com.example.calendar.eventV2" 55 + ], 56 + "action": [ 57 + "create" 58 + ] 59 + }, 60 + { 61 + "type": "permission", 62 + "resource": "rpc", 63 + "aud": "did:web:example.com#foo", 64 + "lxm": [ 65 + "com.example.calendar.listEvents" 66 + ] 67 + }, 68 + { 69 + "type": "permission", 70 + "resource": "rpc", 71 + "aud": "did:web:example.com#bar", 72 + "lxm": [ 73 + "*" 74 + ] 46 75 }, 47 76 { 48 77 "type": "permission", 49 78 "resource": "rpc", 50 79 "inheritAud": true, 80 + "lxm": [ 81 + "com.example.calendar.listEvents" 82 + ] 83 + }, 84 + { 85 + "type": "permission", 86 + "resource": "rpc", 87 + "aud": "*", 51 88 "lxm": [ 52 89 "com.example.calendar.listEvents" 53 90 ]
+1 -1
atproto/lexicon/testdata/catalog/procedure.json
··· 1 1 { 2 2 "lexicon": 1, 3 3 "id": "example.lexicon.procedure", 4 - "description": "demonstrates lexicon features for the procedure type", 5 4 "defs": { 6 5 "main": { 7 6 "type": "procedure", 7 + "description": "demonstrates lexicon features for the procedure type", 8 8 "parameters": { 9 9 "type": "params", 10 10 "properties": {
+1 -2
atproto/lexicon/testdata/catalog/query.json
··· 1 1 { 2 2 "lexicon": 1, 3 3 "id": "example.lexicon.query", 4 - "revision": 1, 5 - "description": "exercizes many lexicon features for the query type", 4 + "description": "exercises many lexicon features for the query type", 6 5 "defs": { 7 6 "main": { 8 7 "type": "query",
-1
atproto/lexicon/testdata/catalog/record.json
··· 1 1 { 2 2 "lexicon": 1, 3 3 "id": "example.lexicon.record", 4 - "revision": 1, 5 4 "description": "demonstrates lexicon features for the record type", 6 5 "defs": { 7 6 "main": {
+1
atproto/lexicon/testdata/catalog/subscription.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "subscription", 8 + "description": "an example event stream", 8 9 "parameters": { 9 10 "type": "params", 10 11 "properties": {
+1 -1
atproto/repo/cmd/repo-tool/firehose.go
··· 36 36 } 37 37 u.Path = "xrpc/com.atproto.sync.subscribeRepos" 38 38 con, _, err := dialer.Dial(u.String(), http.Header{ 39 - "User-Agent": []string{fmt.Sprintf("goat/%s", versioninfo.Short())}, 39 + "User-Agent": []string{fmt.Sprintf("at-repo-tool/%s", versioninfo.Short())}, 40 40 }) 41 41 if err != nil { 42 42 return fmt.Errorf("subscribing to firehose failed (dialing): %w", err)
+7 -7
atproto/repo/commit.go
··· 4 4 "bytes" 5 5 "fmt" 6 6 7 - "github.com/bluesky-social/indigo/atproto/crypto" 8 - "github.com/bluesky-social/indigo/atproto/data" 7 + "github.com/bluesky-social/indigo/atproto/atcrypto" 8 + "github.com/bluesky-social/indigo/atproto/atdata" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 11 11 "github.com/ipfs/go-cid" ··· 45 45 d := map[string]any{ 46 46 "did": c.DID, 47 47 "version": c.Version, 48 - "prev": (*data.CIDLink)(c.Prev), 49 - "data": data.CIDLink(c.Data), 48 + "prev": (*atdata.CIDLink)(c.Prev), 49 + "data": atdata.CIDLink(c.Data), 50 50 } 51 51 if c.Sig != nil { 52 - d["sig"] = data.Bytes(c.Sig) 52 + d["sig"] = atdata.Bytes(c.Sig) 53 53 } 54 54 if c.Rev != "" { 55 55 d["rev"] = c.Rev ··· 80 80 } 81 81 82 82 // Signs the commit, storing the signature in the `Sig` field 83 - func (c *Commit) Sign(privkey crypto.PrivateKey) error { 83 + func (c *Commit) Sign(privkey atcrypto.PrivateKey) error { 84 84 b, err := c.UnsignedBytes() 85 85 if err != nil { 86 86 return err ··· 94 94 } 95 95 96 96 // Verifies `Sig` field using the provided key. Returns `nil` if signature is valid. 97 - func (c *Commit) VerifySignature(pubkey crypto.PublicKey) error { 97 + func (c *Commit) VerifySignature(pubkey atcrypto.PublicKey) error { 98 98 if c.Sig == nil { 99 99 return fmt.Errorf("can not verify unsigned commit") 100 100 }
+4 -4
automod/engine/blobs.go
··· 7 7 "strings" 8 8 "time" 9 9 10 - "github.com/bluesky-social/indigo/atproto/data" 10 + "github.com/bluesky-social/indigo/atproto/atdata" 11 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 12 13 13 "github.com/carlmjohnson/versioninfo" ··· 22 22 return []lexutil.LexBlob{}, nil 23 23 } 24 24 25 - rec, err := data.UnmarshalCBOR(c.RecordOp.RecordCBOR) 25 + rec, err := atdata.UnmarshalCBOR(c.RecordOp.RecordCBOR) 26 26 if err != nil { 27 27 return nil, fmt.Errorf("parsing generic record CBOR: %v", err) 28 28 } 29 - blobs := data.ExtractBlobs(rec) 29 + blobs := atdata.ExtractBlobs(rec) 30 30 31 - // convert from data.Blob to lexutil.LexBlob; plan is to merge these types eventually 31 + // convert from atdata.Blob to lexutil.LexBlob; plan is to merge these types eventually 32 32 var out []lexutil.LexBlob 33 33 for _, b := range blobs { 34 34 lb := lexutil.LexBlob{
+2 -2
cmd/astrolabe/handlers.go
··· 9 9 "github.com/bluesky-social/indigo/api/agnostic" 10 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 11 _ "github.com/bluesky-social/indigo/api/bsky" 12 - "github.com/bluesky-social/indigo/atproto/data" 12 + "github.com/bluesky-social/indigo/atproto/atdata" 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 "github.com/bluesky-social/indigo/xrpc" ··· 231 231 return fmt.Errorf("empty record in response") 232 232 } 233 233 234 - record, err := data.UnmarshalJSON(*resp.Value) 234 + record, err := atdata.UnmarshalJSON(*resp.Value) 235 235 if err != nil { 236 236 return fmt.Errorf("fetched record was invalid data: %w", err) 237 237 }
+1 -167
cmd/goat/README.md
··· 1 1 `goat`: Go AT protocol CLI tool 2 2 =============================== 3 3 4 - **NOTE: this project is moving to a dedicated git repo at [bluesky-social/goat](https://github.com/bluesky-social/goat). This copy of the code is deprecated and will eventually be removed, though a notice will remain.** 5 - 6 - 7 - This is a re-implementation of [adenosine-cli](https://gitlab.com/bnewbold/adenosine/-/tree/main/adenosine-cli?ref_type=heads) in golang. 8 - 9 - 10 - ## Install 11 - 12 - If you have the Go toolchain installed and configured correctly, you can directly build and install the tool for your local account: 13 - 14 - ```bash 15 - go install github.com/bluesky-social/goat@latest 16 - ``` 17 - 18 - A more manual way to install is: 19 - 20 - ```bash 21 - git clone https://github.com/bluesky-social/goat 22 - go build . 23 - sudo cp goat /usr/local/bin 24 - ``` 25 - 26 - The intention is to also provide a Homebrew "cask" and Debian/Ubuntu packages. 27 - 28 - 29 - ## Usage 30 - 31 - `goat` is relatively self-documenting via help pages: 32 - 33 - ```bash 34 - goat --help 35 - goat bsky -h 36 - goat help bsky 37 - # etc 38 - ``` 39 - 40 - Most commands use public APIs are don't require authentication. Some commands, like creating records, require an atproto account. You can log in using an "app password" with `goat account login -u <handle> -p <app-password>`. 41 - 42 - WARNING: `goat` will store both the app password and authentication tokens in the current users home directory, in cleartext. `goat logout` will wipe the file. Intention is to eventually support configuration via environment variables to keep sensitive state in a password manager or otherwise not-cleartext-on-disk. 43 - 44 - Some commands output JSON, and you can use tools like `jq` to process them. 45 - 46 - ## Examples 47 - 48 - Resolve an account's identity in the network: 49 - 50 - ```bash 51 - $ goat resolve wyden.senate.gov 52 - { 53 - "id": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc", 54 - "alsoKnownAs": [ 55 - "at://wyden.senate.gov" 56 - ], 57 - "verificationMethod": [ 58 - { 59 - "id": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc#atproto", 60 - "type": "Multikey", 61 - "controller": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc", 62 - "publicKeyMultibase": "zQ3shuMW7q4KBdsFcdvebGi2EVv8KcqS24tF9Pg7Wh5NLB2NM" 63 - } 64 - ], 65 - "service": [ 66 - { 67 - "id": "#atproto_pds", 68 - "type": "AtprotoPersonalDataServer", 69 - "serviceEndpoint": "https://shimeji.us-east.host.bsky.network" 70 - } 71 - ] 72 - } 73 - ``` 74 - 75 - List record collection types for an account: 76 - 77 - ```bash 78 - $ goat ls -c dril.bsky.social 79 - app.bsky.actor.profile 80 - app.bsky.feed.post 81 - app.bsky.feed.repost 82 - app.bsky.graph.follow 83 - chat.bsky.actor.declaration 84 - ``` 85 - 86 - Fetch a record from the network as JSON: 87 - 88 - ```bash 89 - $ goat get at://dril.bsky.social/app.bsky.feed.post/3kkreaz3amd27 90 - { 91 - "$type": "app.bsky.feed.post", 92 - "createdAt": "2024-02-06T18:15:19.802Z", 93 - "langs": [ 94 - "en" 95 - ], 96 - "text": "I do not Fucking recall them asking the blue sky elders permission to open registration to commoners ." 97 - } 98 - ``` 99 - 100 - Make a public snapshot of your account: 101 - 102 - ```bash 103 - $ goat repo export jay.bsky.team 104 - downloading from https://morel.us-east.host.bsky.network to: jay.bsky.team.20240811183155.car 105 - 106 - $ downloading blobs to: jay.bsky.team_blobs 107 - jay.bsky.team_blobs/bafkreia2x4faux5y7v7v54yl5ebkbaek7z7nhmsd4cooubz3yj4zox34cq downloaded 108 - jay.bsky.team_blobs/bafkreia3qgbww7odprmysd6jcyxoh5sczkwoxinnmzpsp73gs623fqfm3a downloaded 109 - jay.bsky.team_blobs/bafkreia3rgnywdrysy65vid42ulyno2cybxhxrn3ragm7cw3smmsxzvbs4 downloaded 110 - [...] 111 - ``` 112 - 113 - Show PLC history for a single account, or make a snapshot of all PLC records (this takes a while), or monitor new ops: 114 - 115 - ```bash 116 - $ goat plc history atproto.com 117 - [...] 118 - 119 - $ goat plc dump | pv -l | gzip > plc_snapshot.json.gz 120 - [...] 121 - 122 - $ goat plc dump --cursor now --tail 123 - [...] 124 - ``` 125 - 126 - Verify syntax and generate TIDs: 127 - 128 - ```bash 129 - $ goat syntax handle check xn--fiqa61au8b7zsevnm8ak20mc4a87e.xn--fiqs8s 130 - valid 131 - 132 - $ goat syntax rkey check dHJ1ZQ== 133 - error: recordkey syntax didn't validate via regex 134 - 135 - $ goat syntax tid inspect 3kzifvcppte22 136 - Timestamp (UTC): 2024-08-12T02:08:03.29Z 137 - Timestamp (Local): 2024-08-11T19:08:03-07:00 138 - ClockID: 0 139 - uint64: 0x187dcbda2b5ca800 140 - ``` 141 - 142 - The `firehose` commands subscribes to the repo commit stream from a Relay. The default stream outputs event metadata, but doesn't include record blocks (bytes). The `--ops` variant will unpack records and output one line per record operation (instead of one line per commit event), and includes the record values themselves. Some example invocations: 143 - 144 - ```bash 145 - # possible handle updates 146 - $ goat firehose --account-events | jq .payload.handle 147 - [...] 148 - 149 - # text of posts (empty lines for post-deletions) 150 - $ goat firehose - app.bsky.feed.post --ops | jq .record.text 151 - [...] 152 - 153 - # sample ratio of languages in current posts 154 - $ goat firehose --ops -c app.bsky.feed.post | head -n100 | jq .record.langs[0] -c | sort | uniq -c | sort -nr 155 - 51 "en" 156 - 33 "ja" 157 - 7 null 158 - 3 "pt" 159 - 2 "ko" 160 - 1 "th" 161 - 1 "id" 162 - 1 "es" 163 - 1 "am" 164 - ``` 165 - 166 - A minimal bsky posting interface, requires account login: 167 - 168 - ```bash 169 - $ goat bsky post "hello from goat" 170 - ``` 4 + **NOTE: this project has been moved to a dedicated git repo at [bluesky-social/goat](https://github.com/bluesky-social/goat)**
-521
cmd/goat/account.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "strings" 8 - "time" 9 - 10 - comatproto "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/atproto/auth" 12 - "github.com/bluesky-social/indigo/atproto/crypto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "github.com/bluesky-social/indigo/xrpc" 15 - 16 - "github.com/urfave/cli/v2" 17 - ) 18 - 19 - var cmdAccount = &cli.Command{ 20 - Name: "account", 21 - Usage: "sub-commands for auth and account management", 22 - Flags: []cli.Flag{}, 23 - Subcommands: []*cli.Command{ 24 - &cli.Command{ 25 - Name: "login", 26 - Usage: "create session with PDS instance", 27 - Flags: []cli.Flag{ 28 - &cli.StringFlag{ 29 - Name: "username", 30 - Aliases: []string{"u"}, 31 - Required: true, 32 - Usage: "account identifier (handle or DID)", 33 - EnvVars: []string{"ATP_AUTH_USERNAME"}, 34 - }, 35 - &cli.StringFlag{ 36 - Name: "app-password", 37 - Aliases: []string{"p"}, 38 - Required: true, 39 - Usage: "password (app password recommended)", 40 - EnvVars: []string{"ATP_AUTH_PASSWORD"}, 41 - }, 42 - &cli.StringFlag{ 43 - Name: "auth-factor-token", 44 - Usage: "token required if password is used and 2fa is required", 45 - EnvVars: []string{"ATP_AUTH_FACTOR_TOKEN"}, 46 - }, 47 - &cli.StringFlag{ 48 - Name: "pds-host", 49 - Usage: "URL of the PDS to create account on (overrides DID doc)", 50 - EnvVars: []string{"ATP_PDS_HOST"}, 51 - }, 52 - }, 53 - Action: runAccountLogin, 54 - }, 55 - &cli.Command{ 56 - Name: "logout", 57 - Usage: "delete any current session", 58 - Action: runAccountLogout, 59 - }, 60 - &cli.Command{ 61 - Name: "activate", 62 - Usage: "(re)activate current account", 63 - Action: runAccountActivate, 64 - }, 65 - &cli.Command{ 66 - Name: "deactivate", 67 - Usage: "deactivate current account", 68 - Action: runAccountDeactivate, 69 - }, 70 - &cli.Command{ 71 - Name: "lookup", 72 - Usage: "show basic account hosting status for any account", 73 - ArgsUsage: `<at-identifier>`, 74 - Action: runAccountLookup, 75 - }, 76 - &cli.Command{ 77 - Name: "update-handle", 78 - Usage: "change handle for current account", 79 - ArgsUsage: `<handle>`, 80 - Action: runAccountUpdateHandle, 81 - }, 82 - &cli.Command{ 83 - Name: "status", 84 - Usage: "show current account status at PDS", 85 - Action: runAccountStatus, 86 - }, 87 - &cli.Command{ 88 - Name: "missing-blobs", 89 - Usage: "list any missing blobs for current account", 90 - Action: runAccountMissingBlobs, 91 - }, 92 - &cli.Command{ 93 - Name: "service-auth", 94 - Usage: "ask the PDS to create a service auth token", 95 - Flags: []cli.Flag{ 96 - &cli.StringFlag{ 97 - Name: "endpoint", 98 - Aliases: []string{"lxm"}, 99 - Usage: "restrict token to API endpoint (NSID, optional)", 100 - }, 101 - &cli.StringFlag{ 102 - Name: "audience", 103 - Aliases: []string{"aud"}, 104 - Required: true, 105 - Usage: "DID of service that will receive and validate token", 106 - }, 107 - &cli.IntFlag{ 108 - Name: "duration-sec", 109 - Value: 60, 110 - Usage: "validity time window of token (seconds)", 111 - }, 112 - }, 113 - Action: runAccountServiceAuth, 114 - }, 115 - &cli.Command{ 116 - Name: "service-auth-offline", 117 - Usage: "create service auth token via locally-held signing key", 118 - Flags: []cli.Flag{ 119 - &cli.StringFlag{ 120 - Name: "atproto-signing-key", 121 - Required: true, 122 - Usage: "private key used to sign the token (multibase syntax)", 123 - EnvVars: []string{"ATPROTO_SIGNING_KEY"}, 124 - }, 125 - &cli.StringFlag{ 126 - Name: "iss", 127 - Required: true, 128 - Usage: "the DID of the account issuing the token", 129 - }, 130 - &cli.StringFlag{ 131 - Name: "endpoint", 132 - Aliases: []string{"lxm"}, 133 - Usage: "restrict token to API endpoint (NSID, optional)", 134 - }, 135 - &cli.StringFlag{ 136 - Name: "audience", 137 - Aliases: []string{"aud"}, 138 - Required: true, 139 - Usage: "DID of service that will receive and validate token", 140 - }, 141 - &cli.IntFlag{ 142 - Name: "duration-sec", 143 - Value: 60, 144 - Usage: "validity time window of token (seconds)", 145 - }, 146 - }, 147 - Action: runAccountServiceAuthOffline, 148 - }, 149 - &cli.Command{ 150 - Name: "create", 151 - Usage: "create a new account on the indicated PDS host", 152 - Flags: []cli.Flag{ 153 - &cli.StringFlag{ 154 - Name: "pds-host", 155 - Usage: "URL of the PDS to create account on", 156 - Required: true, 157 - EnvVars: []string{"ATP_PDS_HOST"}, 158 - }, 159 - &cli.StringFlag{ 160 - Name: "handle", 161 - Usage: "handle for new account", 162 - Required: true, 163 - EnvVars: []string{"ATP_AUTH_HANDLE"}, 164 - }, 165 - &cli.StringFlag{ 166 - Name: "password", 167 - Usage: "initial account password", 168 - Required: true, 169 - EnvVars: []string{"ATP_AUTH_PASSWORD"}, 170 - }, 171 - &cli.StringFlag{ 172 - Name: "invite-code", 173 - Usage: "invite code for account signup", 174 - }, 175 - &cli.StringFlag{ 176 - Name: "email", 177 - Usage: "email address for new account", 178 - }, 179 - &cli.StringFlag{ 180 - Name: "existing-did", 181 - Usage: "an existing DID to use (eg, non-PLC DID, or migration)", 182 - }, 183 - &cli.StringFlag{ 184 - Name: "recovery-key", 185 - Usage: "public cryptographic key (did:key) to add as PLC recovery", 186 - }, 187 - &cli.StringFlag{ 188 - Name: "service-auth", 189 - Usage: "service auth token (for account migration)", 190 - }, 191 - }, 192 - Action: runAccountCreate, 193 - }, 194 - cmdAccountMigrate, 195 - cmdAccountPlc, 196 - }, 197 - } 198 - 199 - func runAccountLogin(cctx *cli.Context) error { 200 - ctx := context.Background() 201 - 202 - username, err := syntax.ParseAtIdentifier(cctx.String("username")) 203 - if err != nil { 204 - return err 205 - } 206 - 207 - _, err = refreshAuthSession(ctx, *username, cctx.String("app-password"), cctx.String("pds-host"), cctx.String("auth-factor-token")) 208 - return err 209 - } 210 - 211 - func runAccountLogout(cctx *cli.Context) error { 212 - return wipeAuthSession() 213 - } 214 - 215 - func runAccountLookup(cctx *cli.Context) error { 216 - ctx := context.Background() 217 - username := cctx.Args().First() 218 - if username == "" { 219 - return fmt.Errorf("need to provide username as an argument") 220 - } 221 - ident, err := resolveIdent(ctx, username) 222 - if err != nil { 223 - return err 224 - } 225 - 226 - // create a new API client to connect to the account's PDS 227 - xrpcc := xrpc.Client{ 228 - Host: ident.PDSEndpoint(), 229 - UserAgent: userAgent(), 230 - } 231 - if xrpcc.Host == "" { 232 - return fmt.Errorf("no PDS endpoint for identity") 233 - } 234 - 235 - status, err := comatproto.SyncGetRepoStatus(ctx, &xrpcc, ident.DID.String()) 236 - if err != nil { 237 - return err 238 - } 239 - 240 - fmt.Printf("DID: %s\n", status.Did) 241 - fmt.Printf("Active: %v\n", status.Active) 242 - if status.Status != nil { 243 - fmt.Printf("Status: %s\n", *status.Status) 244 - } 245 - if status.Rev != nil { 246 - fmt.Printf("Repo Rev: %s\n", *status.Rev) 247 - } 248 - return nil 249 - } 250 - 251 - func runAccountStatus(cctx *cli.Context) error { 252 - ctx := context.Background() 253 - 254 - client, err := loadAuthClient(ctx) 255 - if err == ErrNoAuthSession { 256 - return fmt.Errorf("auth required, but not logged in") 257 - } else if err != nil { 258 - return err 259 - } 260 - 261 - status, err := comatproto.ServerCheckAccountStatus(ctx, client) 262 - if err != nil { 263 - return fmt.Errorf("failed checking account status: %w", err) 264 - } 265 - 266 - b, err := json.MarshalIndent(status, "", " ") 267 - if err != nil { 268 - return err 269 - } 270 - fmt.Printf("DID: %s\n", client.Auth.Did) 271 - fmt.Printf("Host: %s\n", client.Host) 272 - fmt.Println(string(b)) 273 - 274 - return nil 275 - } 276 - 277 - func runAccountMissingBlobs(cctx *cli.Context) error { 278 - ctx := context.Background() 279 - 280 - client, err := loadAuthClient(ctx) 281 - if err == ErrNoAuthSession { 282 - return fmt.Errorf("auth required, but not logged in") 283 - } else if err != nil { 284 - return err 285 - } 286 - 287 - cursor := "" 288 - for { 289 - resp, err := comatproto.RepoListMissingBlobs(ctx, client, cursor, 500) 290 - if err != nil { 291 - return err 292 - } 293 - for _, missing := range resp.Blobs { 294 - fmt.Printf("%s\t%s\n", missing.Cid, missing.RecordUri) 295 - } 296 - if resp.Cursor != nil && *resp.Cursor != "" { 297 - cursor = *resp.Cursor 298 - } else { 299 - break 300 - } 301 - } 302 - return nil 303 - } 304 - 305 - func runAccountActivate(cctx *cli.Context) error { 306 - ctx := context.Background() 307 - 308 - client, err := loadAuthClient(ctx) 309 - if err == ErrNoAuthSession { 310 - return fmt.Errorf("auth required, but not logged in") 311 - } else if err != nil { 312 - return err 313 - } 314 - 315 - err = comatproto.ServerActivateAccount(ctx, client) 316 - if err != nil { 317 - return fmt.Errorf("failed activating account: %w", err) 318 - } 319 - 320 - return nil 321 - } 322 - 323 - func runAccountDeactivate(cctx *cli.Context) error { 324 - ctx := context.Background() 325 - 326 - client, err := loadAuthClient(ctx) 327 - if err == ErrNoAuthSession { 328 - return fmt.Errorf("auth required, but not logged in") 329 - } else if err != nil { 330 - return err 331 - } 332 - 333 - err = comatproto.ServerDeactivateAccount(ctx, client, &comatproto.ServerDeactivateAccount_Input{}) 334 - if err != nil { 335 - return fmt.Errorf("failed deactivating account: %w", err) 336 - } 337 - 338 - return nil 339 - } 340 - 341 - func runAccountUpdateHandle(cctx *cli.Context) error { 342 - ctx := context.Background() 343 - 344 - raw := cctx.Args().First() 345 - if raw == "" { 346 - return fmt.Errorf("need to provide new handle as argument") 347 - } 348 - handle, err := syntax.ParseHandle(raw) 349 - if err != nil { 350 - return err 351 - } 352 - 353 - client, err := loadAuthClient(ctx) 354 - if err == ErrNoAuthSession { 355 - return fmt.Errorf("auth required, but not logged in") 356 - } else if err != nil { 357 - return err 358 - } 359 - 360 - err = comatproto.IdentityUpdateHandle(ctx, client, &comatproto.IdentityUpdateHandle_Input{ 361 - Handle: handle.String(), 362 - }) 363 - if err != nil { 364 - return fmt.Errorf("failed updating handle: %w", err) 365 - } 366 - 367 - return nil 368 - } 369 - 370 - func runAccountServiceAuth(cctx *cli.Context) error { 371 - ctx := context.Background() 372 - 373 - client, err := loadAuthClient(ctx) 374 - if err == ErrNoAuthSession { 375 - return fmt.Errorf("auth required, but not logged in") 376 - } else if err != nil { 377 - return err 378 - } 379 - 380 - lxm := cctx.String("endpoint") 381 - if lxm != "" { 382 - _, err := syntax.ParseNSID(lxm) 383 - if err != nil { 384 - return fmt.Errorf("lxm argument must be a valid NSID: %w", err) 385 - } 386 - } 387 - 388 - aud := cctx.String("audience") 389 - // TODO: can aud DID have a fragment? 390 - _, err = syntax.ParseDID(aud) 391 - if err != nil { 392 - return fmt.Errorf("aud argument must be a valid DID: %w", err) 393 - } 394 - 395 - durSec := cctx.Int("duration-sec") 396 - expTimestamp := time.Now().Unix() + int64(durSec) 397 - 398 - resp, err := comatproto.ServerGetServiceAuth(ctx, client, aud, expTimestamp, lxm) 399 - if err != nil { 400 - return fmt.Errorf("failed updating handle: %w", err) 401 - } 402 - 403 - fmt.Println(resp.Token) 404 - 405 - return nil 406 - } 407 - 408 - func runAccountServiceAuthOffline(cctx *cli.Context) error { 409 - privStr := cctx.String("atproto-signing-key") 410 - if privStr == "" { 411 - return fmt.Errorf("private key must be provided") 412 - } 413 - privkey, err := crypto.ParsePrivateMultibase(privStr) 414 - if err != nil { 415 - return fmt.Errorf("failed parsing private key: %w", err) 416 - } 417 - 418 - issString := cctx.String("iss") 419 - // TODO: support fragment identifiers 420 - iss, err := syntax.ParseDID(issString) 421 - if err != nil { 422 - return fmt.Errorf("iss argument must be a valid DID: %w", err) 423 - } 424 - 425 - lxmString := cctx.String("endpoint") 426 - var lxm *syntax.NSID = nil 427 - if lxmString != "" { 428 - lxmTmp, err := syntax.ParseNSID(lxmString) 429 - if err != nil { 430 - return fmt.Errorf("lxm argument must be a valid NSID: %w", err) 431 - } 432 - lxm = &lxmTmp 433 - } 434 - 435 - aud := cctx.String("audience") 436 - // TODO: can aud DID have a fragment? 437 - _, err = syntax.ParseDID(aud) 438 - if err != nil { 439 - return fmt.Errorf("aud argument must be a valid DID: %w", err) 440 - } 441 - 442 - durSec := cctx.Int("duration-sec") 443 - duration := time.Duration(durSec * int(time.Second)) 444 - 445 - token, err := auth.SignServiceAuth(iss, aud, duration, lxm, privkey) 446 - if err != nil { 447 - return fmt.Errorf("failed signing token: %w", err) 448 - } 449 - 450 - fmt.Println(token) 451 - 452 - return nil 453 - } 454 - 455 - func runAccountCreate(cctx *cli.Context) error { 456 - ctx := context.Background() 457 - 458 - // validate args 459 - pdsHost := cctx.String("pds-host") 460 - if !strings.Contains(pdsHost, "://") { 461 - return fmt.Errorf("PDS host is not a url: %s", pdsHost) 462 - } 463 - handle := cctx.String("handle") 464 - _, err := syntax.ParseHandle(handle) 465 - if err != nil { 466 - return err 467 - } 468 - password := cctx.String("password") 469 - params := &comatproto.ServerCreateAccount_Input{ 470 - Handle: handle, 471 - Password: &password, 472 - } 473 - raw := cctx.String("existing-did") 474 - if raw != "" { 475 - _, err := syntax.ParseDID(raw) 476 - if err != nil { 477 - return err 478 - } 479 - s := raw 480 - params.Did = &s 481 - } 482 - raw = cctx.String("email") 483 - if raw != "" { 484 - s := raw 485 - params.Email = &s 486 - } 487 - raw = cctx.String("invite-code") 488 - if raw != "" { 489 - s := raw 490 - params.InviteCode = &s 491 - } 492 - raw = cctx.String("recovery-key") 493 - if raw != "" { 494 - s := raw 495 - params.RecoveryKey = &s 496 - } 497 - 498 - // create a new API client to connect to the account's PDS 499 - xrpcc := xrpc.Client{ 500 - Host: pdsHost, 501 - UserAgent: userAgent(), 502 - } 503 - 504 - raw = cctx.String("service-auth") 505 - if raw != "" && params.Did != nil { 506 - xrpcc.Auth = &xrpc.AuthInfo{ 507 - Did: *params.Did, 508 - AccessJwt: raw, 509 - } 510 - } 511 - 512 - resp, err := comatproto.ServerCreateAccount(ctx, &xrpcc, params) 513 - if err != nil { 514 - return fmt.Errorf("failed to create account: %w", err) 515 - } 516 - 517 - fmt.Println("Success!") 518 - fmt.Printf("DID: %s\n", resp.Did) 519 - fmt.Printf("Handle: %s\n", resp.Handle) 520 - return nil 521 - }
-262
cmd/goat/account_migrate.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "log/slog" 9 - "strings" 10 - "time" 11 - 12 - "github.com/bluesky-social/indigo/api/agnostic" 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - "github.com/bluesky-social/indigo/xrpc" 16 - 17 - "github.com/urfave/cli/v2" 18 - ) 19 - 20 - var cmdAccountMigrate = &cli.Command{ 21 - Name: "migrate", 22 - Usage: "move account to a new PDS. requires full auth.", 23 - Flags: []cli.Flag{ 24 - &cli.StringFlag{ 25 - Name: "pds-host", 26 - Usage: "URL of the new PDS to create account on", 27 - Required: true, 28 - EnvVars: []string{"ATP_PDS_HOST"}, 29 - }, 30 - &cli.StringFlag{ 31 - Name: "new-handle", 32 - Required: true, 33 - Usage: "handle on new PDS", 34 - EnvVars: []string{"NEW_ACCOUNT_HANDLE"}, 35 - }, 36 - &cli.StringFlag{ 37 - Name: "new-password", 38 - Required: true, 39 - Usage: "password on new PDS", 40 - EnvVars: []string{"NEW_ACCOUNT_PASSWORD"}, 41 - }, 42 - &cli.StringFlag{ 43 - Name: "plc-token", 44 - Required: true, 45 - Usage: "token from old PDS authorizing token signature", 46 - EnvVars: []string{"PLC_SIGN_TOKEN"}, 47 - }, 48 - &cli.StringFlag{ 49 - Name: "invite-code", 50 - Usage: "invite code for account signup", 51 - }, 52 - &cli.StringFlag{ 53 - Name: "new-email", 54 - Usage: "email address for new account", 55 - }, 56 - }, 57 - Action: runAccountMigrate, 58 - } 59 - 60 - func runAccountMigrate(cctx *cli.Context) error { 61 - // NOTE: this could check rev / commit before and after and ensure last-minute content additions get lost 62 - ctx := context.Background() 63 - 64 - oldClient, err := loadAuthClient(ctx) 65 - if err == ErrNoAuthSession { 66 - return fmt.Errorf("auth required, but not logged in") 67 - } else if err != nil { 68 - return err 69 - } 70 - did := oldClient.Auth.Did 71 - 72 - newHostURL := cctx.String("pds-host") 73 - if !strings.Contains(newHostURL, "://") { 74 - return fmt.Errorf("PDS host is not a url: %s", newHostURL) 75 - } 76 - newHandle := cctx.String("new-handle") 77 - _, err = syntax.ParseHandle(newHandle) 78 - if err != nil { 79 - return err 80 - } 81 - newPassword := cctx.String("new-password") 82 - plcToken := cctx.String("plc-token") 83 - inviteCode := cctx.String("invite-code") 84 - newEmail := cctx.String("new-email") 85 - 86 - newClient := xrpc.Client{ 87 - Host: newHostURL, 88 - UserAgent: userAgent(), 89 - } 90 - 91 - // connect to new host to discover service DID 92 - newHostDesc, err := comatproto.ServerDescribeServer(ctx, &newClient) 93 - if err != nil { 94 - return fmt.Errorf("failed connecting to new host: %w", err) 95 - } 96 - newHostDID, err := syntax.ParseDID(newHostDesc.Did) 97 - if err != nil { 98 - return err 99 - } 100 - slog.Info("new host", "serviceDID", newHostDID, "url", newHostURL) 101 - 102 - // 1. Create New Account 103 - slog.Info("creating account on new host", "handle", newHandle, "host", newHostURL) 104 - 105 - // get service auth token from old host 106 - // args: (ctx, client, aud string, exp int64, lxm string) 107 - expTimestamp := time.Now().Unix() + 60 108 - createAuthResp, err := comatproto.ServerGetServiceAuth(ctx, oldClient, newHostDID.String(), expTimestamp, "com.atproto.server.createAccount") 109 - if err != nil { 110 - return fmt.Errorf("failed getting service auth token from old host: %w", err) 111 - } 112 - 113 - // then create the new account 114 - createParams := comatproto.ServerCreateAccount_Input{ 115 - Did: &did, 116 - Handle: newHandle, 117 - Password: &newPassword, 118 - } 119 - if newEmail != "" { 120 - createParams.Email = &newEmail 121 - } 122 - if inviteCode != "" { 123 - createParams.InviteCode = &inviteCode 124 - } 125 - 126 - // use service auth for access token, temporarily 127 - newClient.Auth = &xrpc.AuthInfo{ 128 - Did: did, 129 - Handle: newHandle, 130 - AccessJwt: createAuthResp.Token, 131 - RefreshJwt: createAuthResp.Token, 132 - } 133 - createAccountResp, err := comatproto.ServerCreateAccount(ctx, &newClient, &createParams) 134 - if err != nil { 135 - return fmt.Errorf("failed creating new account: %w", err) 136 - } 137 - 138 - if createAccountResp.Did != did { 139 - return fmt.Errorf("new account DID not a match: %s != %s", createAccountResp.Did, did) 140 - } 141 - newClient.Auth.AccessJwt = createAccountResp.AccessJwt 142 - newClient.Auth.RefreshJwt = createAccountResp.RefreshJwt 143 - 144 - // login client on the new host 145 - sess, err := comatproto.ServerCreateSession(ctx, &newClient, &comatproto.ServerCreateSession_Input{ 146 - Identifier: did, 147 - Password: newPassword, 148 - }) 149 - if err != nil { 150 - return fmt.Errorf("failed login to newly created account on new host: %w", err) 151 - } 152 - newClient.Auth = &xrpc.AuthInfo{ 153 - Did: did, 154 - AccessJwt: sess.AccessJwt, 155 - RefreshJwt: sess.RefreshJwt, 156 - } 157 - 158 - // 2. Migrate Data 159 - slog.Info("migrating repo") 160 - repoBytes, err := comatproto.SyncGetRepo(ctx, oldClient, did, "") 161 - if err != nil { 162 - return fmt.Errorf("failed exporting repo: %w", err) 163 - } 164 - err = comatproto.RepoImportRepo(ctx, &newClient, bytes.NewReader(repoBytes)) 165 - if err != nil { 166 - return fmt.Errorf("failed importing repo: %w", err) 167 - } 168 - 169 - slog.Info("migrating preferences") 170 - // TODO: service proxy header for AppView? 171 - prefResp, err := agnostic.ActorGetPreferences(ctx, oldClient) 172 - if err != nil { 173 - return fmt.Errorf("failed fetching old preferences: %w", err) 174 - } 175 - err = agnostic.ActorPutPreferences(ctx, &newClient, &agnostic.ActorPutPreferences_Input{ 176 - Preferences: prefResp.Preferences, 177 - }) 178 - if err != nil { 179 - return fmt.Errorf("failed importing preferences: %w", err) 180 - } 181 - 182 - slog.Info("migrating blobs") 183 - blobCursor := "" 184 - for { 185 - listResp, err := comatproto.SyncListBlobs(ctx, oldClient, blobCursor, did, 100, "") 186 - if err != nil { 187 - return fmt.Errorf("failed listing blobs: %w", err) 188 - } 189 - for _, blobCID := range listResp.Cids { 190 - blobBytes, err := comatproto.SyncGetBlob(ctx, oldClient, blobCID, did) 191 - if err != nil { 192 - slog.Warn("failed downloading blob", "cid", blobCID, "err", err) 193 - continue 194 - } 195 - _, err = comatproto.RepoUploadBlob(ctx, &newClient, bytes.NewReader(blobBytes)) 196 - if err != nil { 197 - slog.Warn("failed uploading blob", "cid", blobCID, "err", err, "size", len(blobBytes)) 198 - } 199 - slog.Info("transferred blob", "cid", blobCID, "size", len(blobBytes)) 200 - } 201 - if listResp.Cursor == nil || *listResp.Cursor == "" { 202 - break 203 - } 204 - blobCursor = *listResp.Cursor 205 - } 206 - 207 - // display migration status 208 - // NOTE: this could check between the old PDS and new PDS, polling in a loop showing progress until all records have been indexed 209 - statusResp, err := comatproto.ServerCheckAccountStatus(ctx, &newClient) 210 - if err != nil { 211 - return fmt.Errorf("failed checking account status: %w", err) 212 - } 213 - slog.Info("account migration status", "status", statusResp) 214 - 215 - // 3. Migrate Identity 216 - // NOTE: to work with did:web or non-PDS-managed did:plc, need to do manual migraiton process 217 - slog.Info("updating identity to new host") 218 - 219 - credsResp, err := agnostic.IdentityGetRecommendedDidCredentials(ctx, &newClient) 220 - if err != nil { 221 - return fmt.Errorf("failed fetching new credentials: %w", err) 222 - } 223 - credsBytes, err := json.Marshal(credsResp) 224 - if err != nil { 225 - return nil 226 - } 227 - 228 - var unsignedOp agnostic.IdentitySignPlcOperation_Input 229 - if err = json.Unmarshal(credsBytes, &unsignedOp); err != nil { 230 - return fmt.Errorf("failed parsing PLC op: %w", err) 231 - } 232 - unsignedOp.Token = &plcToken 233 - 234 - // NOTE: could add additional sanity checks here that any extra rotation keys were retained, and that old alsoKnownAs and service entries are retained? The stakes aren't super high for the later, as PLC has the full history. PLC and the new PDS already implement some basic sanity checks. 235 - 236 - signedPlcOpResp, err := agnostic.IdentitySignPlcOperation(ctx, oldClient, &unsignedOp) 237 - if err != nil { 238 - return fmt.Errorf("failed requesting PLC operation signature: %w", err) 239 - } 240 - 241 - err = agnostic.IdentitySubmitPlcOperation(ctx, &newClient, &agnostic.IdentitySubmitPlcOperation_Input{ 242 - Operation: signedPlcOpResp.Operation, 243 - }) 244 - if err != nil { 245 - return fmt.Errorf("failed submitting PLC operation: %w", err) 246 - } 247 - 248 - // 4. Finalize Migration 249 - slog.Info("activating new account") 250 - 251 - err = comatproto.ServerActivateAccount(ctx, &newClient) 252 - if err != nil { 253 - return fmt.Errorf("failed activating new host: %w", err) 254 - } 255 - err = comatproto.ServerDeactivateAccount(ctx, oldClient, &comatproto.ServerDeactivateAccount_Input{}) 256 - if err != nil { 257 - return fmt.Errorf("failed deactivating old host: %w", err) 258 - } 259 - 260 - slog.Info("account migration completed") 261 - return nil 262 - }
-328
cmd/goat/account_plc.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "os" 8 - "slices" 9 - 10 - "github.com/bluesky-social/indigo/api/agnostic" 11 - comatproto "github.com/bluesky-social/indigo/api/atproto" 12 - "github.com/bluesky-social/indigo/atproto/crypto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "github.com/did-method-plc/go-didplc" 15 - 16 - "github.com/urfave/cli/v2" 17 - ) 18 - 19 - var cmdAccountPlc = &cli.Command{ 20 - Name: "plc", 21 - Usage: "sub-commands for managing PLC DID via PDS host", 22 - Flags: []cli.Flag{ 23 - &cli.StringFlag{ 24 - Name: "plc-host", 25 - Usage: "method, hostname, and port of PLC registry", 26 - Value: "https://plc.directory", 27 - EnvVars: []string{"ATP_PLC_HOST"}, 28 - }, 29 - }, 30 - Subcommands: []*cli.Command{ 31 - &cli.Command{ 32 - Name: "recommended", 33 - Usage: "list recommended DID fields for current account", 34 - Action: runAccountPlcRecommended, 35 - }, 36 - &cli.Command{ 37 - Name: "request-token", 38 - Usage: "request a 2FA token (by email) for signing op", 39 - Action: runAccountPlcRequestToken, 40 - }, 41 - &cli.Command{ 42 - Name: "sign", 43 - Usage: "sign a PLC operation", 44 - ArgsUsage: `<json-file>`, 45 - Action: runAccountPlcSign, 46 - Flags: []cli.Flag{ 47 - &cli.StringFlag{ 48 - Name: "token", 49 - Usage: "2FA token for PLC operation signing request", 50 - }, 51 - }, 52 - }, 53 - &cli.Command{ 54 - Name: "submit", 55 - Usage: "submit a PLC operation (via PDS)", 56 - ArgsUsage: `<json-file>`, 57 - Action: runAccountPlcSubmit, 58 - }, 59 - &cli.Command{ 60 - Name: "current", 61 - Usage: "print current PLC data for account (fetched from directory)", 62 - Action: runAccountPlcCurrent, 63 - }, 64 - &cli.Command{ 65 - Name: "add-rotation-key", 66 - Usage: "add a new rotation key to PLC identity (via PDS)", 67 - ArgsUsage: `<pubkey>`, 68 - Action: runAccountPlcAddRotationKey, 69 - Flags: []cli.Flag{ 70 - &cli.StringFlag{ 71 - Name: "token", 72 - Usage: "2FA token for PLC operation signing request", 73 - }, 74 - &cli.BoolFlag{ 75 - Name: "first", 76 - Usage: "inserts key at the top of key list (highest priority)", 77 - }, 78 - }, 79 - }, 80 - }, 81 - } 82 - 83 - func runAccountPlcRecommended(cctx *cli.Context) error { 84 - ctx := context.Background() 85 - 86 - xrpcc, err := loadAuthClient(ctx) 87 - if err == ErrNoAuthSession { 88 - return fmt.Errorf("auth required, but not logged in") 89 - } else if err != nil { 90 - return err 91 - } 92 - 93 - resp, err := agnostic.IdentityGetRecommendedDidCredentials(ctx, xrpcc) 94 - if err != nil { 95 - return err 96 - } 97 - 98 - b, err := json.MarshalIndent(resp, "", " ") 99 - if err != nil { 100 - return err 101 - } 102 - 103 - fmt.Println(string(b)) 104 - return nil 105 - } 106 - 107 - func runAccountPlcRequestToken(cctx *cli.Context) error { 108 - ctx := context.Background() 109 - 110 - xrpcc, err := loadAuthClient(ctx) 111 - if err == ErrNoAuthSession { 112 - return fmt.Errorf("auth required, but not logged in") 113 - } else if err != nil { 114 - return err 115 - } 116 - 117 - err = comatproto.IdentityRequestPlcOperationSignature(ctx, xrpcc) 118 - if err != nil { 119 - return err 120 - } 121 - 122 - fmt.Println("Success; check email for token.") 123 - return nil 124 - } 125 - 126 - func runAccountPlcSign(cctx *cli.Context) error { 127 - ctx := context.Background() 128 - 129 - opPath := cctx.Args().First() 130 - if opPath == "" { 131 - return fmt.Errorf("need to provide JSON file path as an argument") 132 - } 133 - 134 - xrpcc, err := loadAuthClient(ctx) 135 - if err == ErrNoAuthSession { 136 - return fmt.Errorf("auth required, but not logged in") 137 - } else if err != nil { 138 - return err 139 - } 140 - 141 - fileBytes, err := os.ReadFile(opPath) 142 - if err != nil { 143 - return err 144 - } 145 - 146 - var body agnostic.IdentitySignPlcOperation_Input 147 - if err = json.Unmarshal(fileBytes, &body); err != nil { 148 - return fmt.Errorf("failed decoding PLC op JSON: %w", err) 149 - } 150 - 151 - token := cctx.String("token") 152 - if token != "" { 153 - body.Token = &token 154 - } 155 - 156 - resp, err := agnostic.IdentitySignPlcOperation(ctx, xrpcc, &body) 157 - if err != nil { 158 - return err 159 - } 160 - 161 - b, err := json.MarshalIndent(resp.Operation, "", " ") 162 - if err != nil { 163 - return err 164 - } 165 - 166 - fmt.Println(string(b)) 167 - return nil 168 - } 169 - 170 - func runAccountPlcSubmit(cctx *cli.Context) error { 171 - ctx := context.Background() 172 - 173 - opPath := cctx.Args().First() 174 - if opPath == "" { 175 - return fmt.Errorf("need to provide JSON file path as an argument") 176 - } 177 - 178 - xrpcc, err := loadAuthClient(ctx) 179 - if err == ErrNoAuthSession { 180 - return fmt.Errorf("auth required, but not logged in") 181 - } else if err != nil { 182 - return err 183 - } 184 - 185 - fileBytes, err := os.ReadFile(opPath) 186 - if err != nil { 187 - return err 188 - } 189 - 190 - var opEnum didplc.OpEnum 191 - if err = json.Unmarshal(fileBytes, &opEnum); err != nil { 192 - return fmt.Errorf("failed decoding PLC op JSON: %w", err) 193 - } 194 - op := opEnum.AsOperation() 195 - 196 - if op.IsGenesis() { 197 - return fmt.Errorf("can't submit a genesis operation via PDS (HINT: Make sure the prev field is set, or try `goat plc submit --genesis`)") 198 - } 199 - 200 - if !op.IsSigned() { 201 - return fmt.Errorf("operation must be signed (HINT: try `goat account plc sign`)") 202 - } 203 - 204 - // convert it back to JSON for submission 205 - opEncoded, err := json.Marshal(op) 206 - if err != nil { 207 - return err 208 - } 209 - rawMsg := json.RawMessage(opEncoded) 210 - err = agnostic.IdentitySubmitPlcOperation(ctx, xrpcc, &agnostic.IdentitySubmitPlcOperation_Input{ 211 - Operation: &rawMsg, 212 - }) 213 - 214 - if err != nil { 215 - return fmt.Errorf("failed submitting PLC op via PDS: %w", err) 216 - } 217 - 218 - return nil 219 - } 220 - 221 - func runAccountPlcCurrent(cctx *cli.Context) error { 222 - ctx := context.Background() 223 - 224 - xrpcc, err := loadAuthClient(ctx) 225 - if err == ErrNoAuthSession || xrpcc.Auth == nil { 226 - return fmt.Errorf("auth required, but not logged in") 227 - } else if err != nil { 228 - return err 229 - } 230 - 231 - did, err := syntax.ParseDID(xrpcc.Auth.Did) 232 - if err != nil { 233 - return err 234 - } 235 - 236 - plcData, err := fetchPLCData(ctx, cctx.String("plc-host"), did) 237 - if err != nil { 238 - return err 239 - } 240 - 241 - b, err := json.MarshalIndent(plcData, "", " ") 242 - if err != nil { 243 - return err 244 - } 245 - fmt.Println(string(b)) 246 - return nil 247 - } 248 - 249 - func runAccountPlcAddRotationKey(cctx *cli.Context) error { 250 - ctx := context.Background() 251 - 252 - newKeyStr := cctx.Args().First() 253 - if newKeyStr == "" { 254 - return fmt.Errorf("need to provide public key argument (as did:key)") 255 - } 256 - 257 - // check that it is a valid pubkey 258 - _, err := crypto.ParsePublicDIDKey(newKeyStr) 259 - if err != nil { 260 - return err 261 - } 262 - 263 - xrpcc, err := loadAuthClient(ctx) 264 - if err == ErrNoAuthSession { 265 - return fmt.Errorf("auth required, but not logged in") 266 - } else if err != nil { 267 - return err 268 - } 269 - 270 - did, err := syntax.ParseDID(xrpcc.Auth.Did) 271 - if err != nil { 272 - return err 273 - } 274 - 275 - // 1. fetch current PLC op: plc.directory/{did}/data 276 - plcData, err := fetchPLCData(ctx, cctx.String("plc-host"), did) 277 - if err != nil { 278 - return err 279 - } 280 - 281 - if len(plcData.RotationKeys) >= 5 { 282 - fmt.Println("WARNGING: already have 5 rotation keys, which is the maximum") 283 - } 284 - 285 - for _, k := range plcData.RotationKeys { 286 - if k == newKeyStr { 287 - return fmt.Errorf("key already registered as a rotation key") 288 - } 289 - } 290 - 291 - // 2. update data 292 - if cctx.Bool("first") { 293 - plcData.RotationKeys = slices.Insert(plcData.RotationKeys, 0, newKeyStr) 294 - } else { 295 - plcData.RotationKeys = append(plcData.RotationKeys, newKeyStr) 296 - } 297 - 298 - // 3. get data signed (using token) 299 - opBytes, err := json.Marshal(&plcData) 300 - if err != nil { 301 - return err 302 - } 303 - var body agnostic.IdentitySignPlcOperation_Input 304 - if err = json.Unmarshal(opBytes, &body); err != nil { 305 - return fmt.Errorf("failed decoding PLC op JSON: %w", err) 306 - } 307 - 308 - token := cctx.String("token") 309 - if token != "" { 310 - body.Token = &token 311 - } 312 - 313 - resp, err := agnostic.IdentitySignPlcOperation(ctx, xrpcc, &body) 314 - if err != nil { 315 - return err 316 - } 317 - 318 - // 4. submit signed op 319 - err = agnostic.IdentitySubmitPlcOperation(ctx, xrpcc, &agnostic.IdentitySubmitPlcOperation_Input{ 320 - Operation: resp.Operation, 321 - }) 322 - if err != nil { 323 - return fmt.Errorf("failed submitting PLC op via PDS: %w", err) 324 - } 325 - 326 - fmt.Println("Success!") 327 - return nil 328 - }
-170
cmd/goat/auth.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "errors" 7 - "fmt" 8 - "io/ioutil" 9 - "os" 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 - "github.com/bluesky-social/indigo/xrpc" 15 - 16 - "github.com/adrg/xdg" 17 - ) 18 - 19 - var ErrNoAuthSession = errors.New("no auth session found") 20 - 21 - type AuthSession struct { 22 - DID syntax.DID `json:"did"` 23 - Password string `json:"password"` 24 - RefreshToken string `json:"session_token"` 25 - PDS string `json:"pds"` 26 - } 27 - 28 - func persistAuthSession(sess *AuthSession) error { 29 - 30 - fPath, err := xdg.StateFile("goat/auth-session.json") 31 - if err != nil { 32 - return err 33 - } 34 - 35 - f, err := os.OpenFile(fPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) 36 - if err != nil { 37 - return err 38 - } 39 - defer f.Close() 40 - 41 - authBytes, err := json.MarshalIndent(sess, "", " ") 42 - if err != nil { 43 - return err 44 - } 45 - _, err = f.Write(authBytes) 46 - return err 47 - } 48 - 49 - func loadAuthClient(ctx context.Context) (*xrpc.Client, error) { 50 - 51 - // TODO: could also load from env var / cctx 52 - 53 - fPath, err := xdg.SearchStateFile("goat/auth-session.json") 54 - if err != nil { 55 - return nil, ErrNoAuthSession 56 - } 57 - 58 - fBytes, err := ioutil.ReadFile(fPath) 59 - if err != nil { 60 - return nil, err 61 - } 62 - 63 - var sess AuthSession 64 - err = json.Unmarshal(fBytes, &sess) 65 - if err != nil { 66 - return nil, err 67 - } 68 - 69 - client := xrpc.Client{ 70 - Host: sess.PDS, 71 - UserAgent: userAgent(), 72 - Auth: &xrpc.AuthInfo{ 73 - Did: sess.DID.String(), 74 - // NOTE: using refresh in access location for "refreshSession" call 75 - AccessJwt: sess.RefreshToken, 76 - RefreshJwt: sess.RefreshToken, 77 - }, 78 - } 79 - resp, err := comatproto.ServerRefreshSession(ctx, &client) 80 - if err != nil { 81 - // TODO: if failure, try creating a new session from password (2fa tokens are only valid once, so not reused) 82 - fmt.Println("trying to refresh auth from password...") 83 - as, err := refreshAuthSession(ctx, sess.DID.AtIdentifier(), sess.Password, sess.PDS, "") 84 - if err != nil { 85 - return nil, err 86 - } 87 - client.Auth.AccessJwt = as.RefreshToken 88 - client.Auth.RefreshJwt = as.RefreshToken 89 - resp, err = comatproto.ServerRefreshSession(ctx, &client) 90 - if err != nil { 91 - return nil, err 92 - } 93 - } 94 - client.Auth.AccessJwt = resp.AccessJwt 95 - client.Auth.RefreshJwt = resp.RefreshJwt 96 - 97 - return &client, nil 98 - } 99 - 100 - func refreshAuthSession(ctx context.Context, username syntax.AtIdentifier, password, pdsURL, authFactorToken string) (*AuthSession, error) { 101 - 102 - var did syntax.DID 103 - if pdsURL == "" { 104 - dir := identity.DefaultDirectory() 105 - ident, err := dir.Lookup(ctx, username) 106 - if err != nil { 107 - return nil, err 108 - } 109 - 110 - pdsURL = ident.PDSEndpoint() 111 - if pdsURL == "" { 112 - return nil, fmt.Errorf("empty PDS URL") 113 - } 114 - did = ident.DID 115 - } 116 - 117 - if did == "" && username.IsDID() { 118 - did, _ = username.AsDID() 119 - } 120 - 121 - client := xrpc.Client{ 122 - Host: pdsURL, 123 - UserAgent: userAgent(), 124 - } 125 - var token *string 126 - if authFactorToken != "" { 127 - token = &authFactorToken 128 - } 129 - sess, err := comatproto.ServerCreateSession(ctx, &client, &comatproto.ServerCreateSession_Input{ 130 - Identifier: username.String(), 131 - Password: password, 132 - AuthFactorToken: token, 133 - }) 134 - if err != nil { 135 - return nil, err 136 - } 137 - 138 - // TODO: check account status? 139 - // TODO: warn if email isn't verified? 140 - // TODO: check that sess.Did matches username 141 - if did == "" { 142 - did, err = syntax.ParseDID(sess.Did) 143 - if err != nil { 144 - return nil, err 145 - } 146 - } else if sess.Did != did.String() { 147 - return nil, fmt.Errorf("session DID didn't match expected: %s != %s", sess.Did, did) 148 - } 149 - 150 - authSession := AuthSession{ 151 - DID: did, 152 - Password: password, 153 - PDS: pdsURL, 154 - RefreshToken: sess.RefreshJwt, 155 - } 156 - if err = persistAuthSession(&authSession); err != nil { 157 - return nil, err 158 - } 159 - return &authSession, nil 160 - } 161 - 162 - func wipeAuthSession() error { 163 - 164 - fPath, err := xdg.SearchStateFile("goat/auth-session.json") 165 - if err != nil { 166 - fmt.Printf("no auth session found (already logged out)") 167 - return nil 168 - } 169 - return os.Remove(fPath) 170 - }
-243
cmd/goat/blob.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "os" 9 - 10 - comatproto "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 - 13 - "github.com/urfave/cli/v2" 14 - ) 15 - 16 - var cmdBlob = &cli.Command{ 17 - Name: "blob", 18 - Usage: "sub-commands for blobs", 19 - Flags: []cli.Flag{}, 20 - Subcommands: []*cli.Command{ 21 - &cli.Command{ 22 - Name: "export", 23 - Usage: "download all blobs for given account", 24 - ArgsUsage: `<at-identifier>`, 25 - Flags: []cli.Flag{ 26 - &cli.StringFlag{ 27 - Name: "output", 28 - Aliases: []string{"o"}, 29 - Usage: "directory to store blobs in", 30 - }, 31 - &cli.StringFlag{ 32 - Name: "pds-host", 33 - Usage: "URL of the PDS to export blobs from (overrides DID doc)", 34 - }, 35 - }, 36 - Action: runBlobExport, 37 - }, 38 - &cli.Command{ 39 - Name: "ls", 40 - Aliases: []string{"list"}, 41 - Usage: "list all blobs for account", 42 - ArgsUsage: `<at-identifier>`, 43 - Flags: []cli.Flag{}, 44 - Action: runBlobList, 45 - }, 46 - &cli.Command{ 47 - Name: "download", 48 - Usage: "download a single blob from an account", 49 - ArgsUsage: `<at-identifier> <cid>`, 50 - Flags: []cli.Flag{ 51 - &cli.StringFlag{ 52 - Name: "output", 53 - Aliases: []string{"o"}, 54 - Usage: "file path to store blob at", 55 - }, 56 - }, 57 - Action: runBlobDownload, 58 - }, 59 - &cli.Command{ 60 - Name: "upload", 61 - Usage: "upload a file", 62 - ArgsUsage: `<file>`, 63 - Flags: []cli.Flag{}, 64 - Action: runBlobUpload, 65 - }, 66 - }, 67 - } 68 - 69 - func runBlobExport(cctx *cli.Context) error { 70 - ctx := context.Background() 71 - username := cctx.Args().First() 72 - if username == "" { 73 - return fmt.Errorf("need to provide username as an argument") 74 - } 75 - ident, err := resolveIdent(ctx, username) 76 - if err != nil { 77 - return err 78 - } 79 - 80 - pdsHost := cctx.String("pds-host") 81 - if pdsHost == "" { 82 - pdsHost = ident.PDSEndpoint() 83 - } 84 - 85 - // create a new API client to connect to the account's PDS 86 - xrpcc := xrpc.Client{ 87 - Host: pdsHost, 88 - UserAgent: userAgent(), 89 - } 90 - if xrpcc.Host == "" { 91 - return fmt.Errorf("no PDS endpoint for identity") 92 - } 93 - 94 - topDir := cctx.String("output") 95 - if topDir == "" { 96 - topDir = fmt.Sprintf("%s_blobs", username) 97 - } 98 - 99 - fmt.Printf("downloading blobs to: %s\n", topDir) 100 - os.MkdirAll(topDir, os.ModePerm) 101 - 102 - cursor := "" 103 - for { 104 - resp, err := comatproto.SyncListBlobs(ctx, &xrpcc, cursor, ident.DID.String(), 500, "") 105 - if err != nil { 106 - return err 107 - } 108 - for _, cidStr := range resp.Cids { 109 - blobPath := topDir + "/" + cidStr 110 - if _, err := os.Stat(blobPath); err == nil { 111 - fmt.Printf("%s\texists\n", blobPath) 112 - continue 113 - } 114 - blobBytes, err := comatproto.SyncGetBlob(ctx, &xrpcc, cidStr, ident.DID.String()) 115 - if err != nil { 116 - fmt.Printf("%s\tfailed %s\n", blobPath, err) 117 - continue 118 - } 119 - if err := os.WriteFile(blobPath, blobBytes, 0666); err != nil { 120 - return err 121 - } 122 - fmt.Printf("%s\tdownloaded\n", blobPath) 123 - } 124 - if resp.Cursor != nil && *resp.Cursor != "" { 125 - cursor = *resp.Cursor 126 - } else { 127 - break 128 - } 129 - } 130 - return nil 131 - } 132 - 133 - func runBlobList(cctx *cli.Context) error { 134 - ctx := context.Background() 135 - username := cctx.Args().First() 136 - if username == "" { 137 - return fmt.Errorf("need to provide username as an argument") 138 - } 139 - ident, err := resolveIdent(ctx, username) 140 - if err != nil { 141 - return err 142 - } 143 - 144 - // create a new API client to connect to the account's PDS 145 - xrpcc := xrpc.Client{ 146 - Host: ident.PDSEndpoint(), 147 - UserAgent: userAgent(), 148 - } 149 - if xrpcc.Host == "" { 150 - return fmt.Errorf("no PDS endpoint for identity") 151 - } 152 - 153 - cursor := "" 154 - for { 155 - resp, err := comatproto.SyncListBlobs(ctx, &xrpcc, cursor, ident.DID.String(), 500, "") 156 - if err != nil { 157 - return err 158 - } 159 - for _, cidStr := range resp.Cids { 160 - fmt.Println(cidStr) 161 - } 162 - if resp.Cursor != nil && *resp.Cursor != "" { 163 - cursor = *resp.Cursor 164 - } else { 165 - break 166 - } 167 - } 168 - return nil 169 - } 170 - 171 - func runBlobDownload(cctx *cli.Context) error { 172 - ctx := context.Background() 173 - username := cctx.Args().First() 174 - if username == "" { 175 - return fmt.Errorf("need to provide username as an argument") 176 - } 177 - if cctx.Args().Len() < 2 { 178 - return fmt.Errorf("need to provide blob CID as second argument") 179 - } 180 - blobCID := cctx.Args().Get(1) 181 - ident, err := resolveIdent(ctx, username) 182 - if err != nil { 183 - return err 184 - } 185 - 186 - // create a new API client to connect to the account's PDS 187 - xrpcc := xrpc.Client{ 188 - Host: ident.PDSEndpoint(), 189 - UserAgent: userAgent(), 190 - } 191 - if xrpcc.Host == "" { 192 - return fmt.Errorf("no PDS endpoint for identity") 193 - } 194 - 195 - blobPath := cctx.String("output") 196 - if blobPath == "" { 197 - blobPath = blobCID 198 - } 199 - 200 - fmt.Printf("downloading blob to: %s\n", blobCID) 201 - 202 - if _, err := os.Stat(blobPath); err == nil { 203 - return fmt.Errorf("file exists: %s", blobPath) 204 - } 205 - blobBytes, err := comatproto.SyncGetBlob(ctx, &xrpcc, blobCID, ident.DID.String()) 206 - if err != nil { 207 - return err 208 - } 209 - return os.WriteFile(blobPath, blobBytes, 0666) 210 - } 211 - 212 - func runBlobUpload(cctx *cli.Context) error { 213 - ctx := context.Background() 214 - blobPath := cctx.Args().First() 215 - if blobPath == "" { 216 - return fmt.Errorf("need to provide file path as an argument") 217 - } 218 - 219 - xrpcc, err := loadAuthClient(ctx) 220 - if err == ErrNoAuthSession { 221 - return fmt.Errorf("auth required, but not logged in") 222 - } else if err != nil { 223 - return err 224 - } 225 - 226 - fileBytes, err := os.ReadFile(blobPath) 227 - if err != nil { 228 - return err 229 - } 230 - 231 - resp, err := comatproto.RepoUploadBlob(ctx, xrpcc, bytes.NewReader(fileBytes)) 232 - if err != nil { 233 - return err 234 - } 235 - 236 - b, err := json.MarshalIndent(resp.Blob, "", " ") 237 - if err != nil { 238 - return err 239 - } 240 - 241 - fmt.Println(string(b)) 242 - return nil 243 - }
-64
cmd/goat/bsky.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - 7 - comatproto "github.com/bluesky-social/indigo/api/atproto" 8 - appbsky "github.com/bluesky-social/indigo/api/bsky" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - lexutil "github.com/bluesky-social/indigo/lex/util" 11 - 12 - "github.com/urfave/cli/v2" 13 - ) 14 - 15 - var cmdBsky = &cli.Command{ 16 - Name: "bsky", 17 - Usage: "sub-commands for bsky app", 18 - Flags: []cli.Flag{}, 19 - Subcommands: []*cli.Command{ 20 - &cli.Command{ 21 - Name: "post", 22 - Usage: "create a post", 23 - ArgsUsage: `<text>`, 24 - Action: runBskyPost, 25 - }, 26 - cmdBskyPrefs, 27 - }, 28 - } 29 - 30 - func runBskyPost(cctx *cli.Context) error { 31 - ctx := context.Background() 32 - text := cctx.Args().First() 33 - if text == "" { 34 - return fmt.Errorf("need to provide post text as argument") 35 - } 36 - 37 - xrpcc, err := loadAuthClient(ctx) 38 - if err == ErrNoAuthSession { 39 - return fmt.Errorf("auth required, but not logged in") 40 - } else if err != nil { 41 - return err 42 - } 43 - 44 - post := appbsky.FeedPost{ 45 - Text: text, 46 - CreatedAt: syntax.DatetimeNow().String(), 47 - } 48 - resp, err := comatproto.RepoCreateRecord(ctx, xrpcc, &comatproto.RepoCreateRecord_Input{ 49 - Collection: "app.bsky.feed.post", 50 - Repo: xrpcc.Auth.Did, 51 - Record: &lexutil.LexiconTypeDecoder{Val: &post}, 52 - }) 53 - if err != nil { 54 - return err 55 - } 56 - 57 - fmt.Printf("%s\t%s\n", resp.Uri, resp.Cid) 58 - aturi, err := syntax.ParseATURI(resp.Uri) 59 - if err != nil { 60 - return err 61 - } 62 - fmt.Printf("view post at: https://bsky.app/profile/%s/post/%s\n", aturi.Authority(), aturi.RecordKey()) 63 - return nil 64 - }
-90
cmd/goat/bsky_prefs.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "os" 8 - 9 - "github.com/bluesky-social/indigo/api/agnostic" 10 - 11 - "github.com/urfave/cli/v2" 12 - ) 13 - 14 - var cmdBskyPrefs = &cli.Command{ 15 - Name: "prefs", 16 - Usage: "sub-commands for preferences", 17 - Flags: []cli.Flag{}, 18 - Subcommands: []*cli.Command{ 19 - &cli.Command{ 20 - Name: "export", 21 - Usage: "dump preferences out as JSON", 22 - Action: runBskyPrefsExport, 23 - }, 24 - &cli.Command{ 25 - Name: "import", 26 - Usage: "upload preferences from JSON file", 27 - ArgsUsage: `<file>`, 28 - Action: runBskyPrefsImport, 29 - }, 30 - }, 31 - } 32 - 33 - func runBskyPrefsExport(cctx *cli.Context) error { 34 - ctx := context.Background() 35 - 36 - xrpcc, err := loadAuthClient(ctx) 37 - if err == ErrNoAuthSession { 38 - return fmt.Errorf("auth required, but not logged in") 39 - } else if err != nil { 40 - return err 41 - } 42 - 43 - // TODO: does indigo API code crash with unsupported preference '$type'? Eg "Lexicon decoder" with unsupported type. 44 - resp, err := agnostic.ActorGetPreferences(ctx, xrpcc) 45 - if err != nil { 46 - return fmt.Errorf("failed fetching old preferences: %w", err) 47 - } 48 - 49 - b, err := json.MarshalIndent(resp.Preferences, "", " ") 50 - if err != nil { 51 - return err 52 - } 53 - fmt.Println(string(b)) 54 - 55 - return nil 56 - } 57 - 58 - func runBskyPrefsImport(cctx *cli.Context) error { 59 - ctx := context.Background() 60 - prefsPath := cctx.Args().First() 61 - if prefsPath == "" { 62 - return fmt.Errorf("need to provide file path as an argument") 63 - } 64 - 65 - xrpcc, err := loadAuthClient(ctx) 66 - if err == ErrNoAuthSession { 67 - return fmt.Errorf("auth required, but not logged in") 68 - } else if err != nil { 69 - return err 70 - } 71 - 72 - prefsBytes, err := os.ReadFile(prefsPath) 73 - if err != nil { 74 - return err 75 - } 76 - 77 - var prefsArray []map[string]any 78 - if err = json.Unmarshal(prefsBytes, &prefsArray); err != nil { 79 - return err 80 - } 81 - 82 - err = agnostic.ActorPutPreferences(ctx, xrpcc, &agnostic.ActorPutPreferences_Input{ 83 - Preferences: prefsArray, 84 - }) 85 - if err != nil { 86 - return fmt.Errorf("failed fetching old preferences: %w", err) 87 - } 88 - 89 - return nil 90 - }
-489
cmd/goat/firehose.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "errors" 8 - "fmt" 9 - "log/slog" 10 - "net/http" 11 - "net/url" 12 - "os" 13 - "strings" 14 - "time" 15 - 16 - comatproto "github.com/bluesky-social/indigo/api/atproto" 17 - "github.com/bluesky-social/indigo/atproto/data" 18 - "github.com/bluesky-social/indigo/atproto/identity" 19 - "github.com/bluesky-social/indigo/atproto/repo" 20 - "github.com/bluesky-social/indigo/atproto/syntax" 21 - "github.com/bluesky-social/indigo/events" 22 - "github.com/bluesky-social/indigo/events/schedulers/parallel" 23 - lexutil "github.com/bluesky-social/indigo/lex/util" 24 - 25 - "github.com/gorilla/websocket" 26 - "github.com/urfave/cli/v2" 27 - ) 28 - 29 - var cmdFirehose = &cli.Command{ 30 - Name: "firehose", 31 - Usage: "stream repo and identity events", 32 - Flags: []cli.Flag{ 33 - &cli.StringFlag{ 34 - Name: "relay-host", 35 - Usage: "method, hostname, and port of Relay instance (websocket)", 36 - Value: "wss://bsky.network", 37 - EnvVars: []string{"ATP_RELAY_HOST", "RELAY_HOST"}, 38 - }, 39 - &cli.IntFlag{ 40 - Name: "cursor", 41 - Usage: "cursor to consume at", 42 - }, 43 - &cli.StringSliceFlag{ 44 - Name: "collection", 45 - Aliases: []string{"c"}, 46 - Usage: "filter to specific record types (NSID)", 47 - }, 48 - &cli.BoolFlag{ 49 - Name: "account-events", 50 - Usage: "only print account and identity events", 51 - }, 52 - &cli.BoolFlag{ 53 - Name: "blocks", 54 - Usage: "include blocks as base64 in payload", 55 - }, 56 - &cli.BoolFlag{ 57 - Name: "quiet", 58 - Aliases: []string{"q"}, 59 - Usage: "don't actually print events to stdout (eg, errors only)", 60 - }, 61 - &cli.BoolFlag{ 62 - Name: "verify-basic", 63 - Usage: "parse events and do basic syntax and structure checks", 64 - }, 65 - &cli.BoolFlag{ 66 - Name: "verify-sig", 67 - Usage: "verify account signatures on commits", 68 - }, 69 - &cli.BoolFlag{ 70 - Name: "verify-mst", 71 - Usage: "run inductive verification of ops and MST structure", 72 - }, 73 - &cli.BoolFlag{ 74 - Name: "ops", 75 - Aliases: []string{"records"}, 76 - Usage: "instead of printing entire events, print individual record ops", 77 - }, 78 - }, 79 - Action: runFirehose, 80 - } 81 - 82 - type GoatFirehoseConsumer struct { 83 - OpsMode bool 84 - AccountsOnly bool 85 - Quiet bool 86 - Blocks bool 87 - VerifyBasic bool 88 - VerifySig bool 89 - VerifyMST bool 90 - // filter to specified collections 91 - CollectionFilter []string 92 - // for signature verification 93 - Dir identity.Directory 94 - } 95 - 96 - func runFirehose(cctx *cli.Context) error { 97 - ctx := context.Background() 98 - 99 - slog.SetDefault(configLogger(cctx, os.Stderr)) 100 - 101 - // main thing is skipping handle verification 102 - bdir := identity.BaseDirectory{ 103 - SkipHandleVerification: true, 104 - TryAuthoritativeDNS: false, 105 - SkipDNSDomainSuffixes: []string{".bsky.social"}, 106 - UserAgent: *userAgent(), 107 - } 108 - cdir := identity.NewCacheDirectory(&bdir, 1_000_000, time.Hour*24, time.Minute*2, time.Minute*5) 109 - 110 - gfc := GoatFirehoseConsumer{ 111 - OpsMode: cctx.Bool("ops"), 112 - AccountsOnly: cctx.Bool("account-events"), 113 - CollectionFilter: cctx.StringSlice("collection"), 114 - Quiet: cctx.Bool("quiet"), 115 - Blocks: cctx.Bool("blocks"), 116 - VerifyBasic: cctx.Bool("verify-basic"), 117 - VerifySig: cctx.Bool("verify-sig"), 118 - VerifyMST: cctx.Bool("verify-mst"), 119 - Dir: &cdir, 120 - } 121 - 122 - var relayHost string 123 - if cctx.IsSet("relay-host") { 124 - if cctx.Args().Len() != 0 { 125 - return errors.New("error: unused positional args") 126 - } 127 - relayHost = cctx.String("relay-host") 128 - } else { 129 - if cctx.Args().Len() == 1 { 130 - relayHost = cctx.Args().First() 131 - } else if cctx.Args().Len() > 1 { 132 - return errors.New("can only have at most one relay-host") 133 - } else { 134 - relayHost = cctx.String("relay-host") 135 - } 136 - } 137 - 138 - dialer := websocket.DefaultDialer 139 - u, err := url.Parse(relayHost) 140 - if err != nil { 141 - return fmt.Errorf("invalid relayHost URI: %w", err) 142 - } 143 - switch u.Scheme { 144 - case "http": 145 - u.Scheme = "ws" 146 - case "https": 147 - u.Scheme = "wss" 148 - } 149 - u.Path = "xrpc/com.atproto.sync.subscribeRepos" 150 - if cctx.IsSet("cursor") { 151 - u.RawQuery = fmt.Sprintf("cursor=%d", cctx.Int("cursor")) 152 - } 153 - urlString := u.String() 154 - con, _, err := dialer.Dial(urlString, http.Header{ 155 - "User-Agent": []string{*userAgent()}, 156 - }) 157 - if err != nil { 158 - return fmt.Errorf("subscribing to firehose failed (dialing): %w", err) 159 - } 160 - 161 - rsc := &events.RepoStreamCallbacks{ 162 - RepoCommit: func(evt *comatproto.SyncSubscribeRepos_Commit) error { 163 - //slog.Debug("commit event", "did", evt.Repo, "seq", evt.Seq) 164 - if !gfc.AccountsOnly && !gfc.OpsMode { 165 - return gfc.handleCommitEvent(ctx, evt) 166 - } else if !gfc.AccountsOnly && gfc.OpsMode { 167 - return gfc.handleCommitEventOps(ctx, evt) 168 - } 169 - return nil 170 - }, 171 - RepoSync: func(evt *comatproto.SyncSubscribeRepos_Sync) error { 172 - //slog.Debug("sync event", "did", evt.Did, "seq", evt.Seq) 173 - if !gfc.AccountsOnly && !gfc.OpsMode { 174 - return gfc.handleSyncEvent(ctx, evt) 175 - } 176 - return nil 177 - }, 178 - RepoIdentity: func(evt *comatproto.SyncSubscribeRepos_Identity) error { 179 - //slog.Debug("identity event", "did", evt.Did, "seq", evt.Seq) 180 - if !gfc.OpsMode { 181 - return gfc.handleIdentityEvent(ctx, evt) 182 - } 183 - return nil 184 - }, 185 - RepoAccount: func(evt *comatproto.SyncSubscribeRepos_Account) error { 186 - //slog.Debug("account event", "did", evt.Did, "seq", evt.Seq) 187 - if !gfc.OpsMode { 188 - return gfc.handleAccountEvent(ctx, evt) 189 - } 190 - return nil 191 - }, 192 - } 193 - 194 - scheduler := parallel.NewScheduler( 195 - 1, 196 - 100, 197 - relayHost, 198 - rsc.EventHandler, 199 - ) 200 - slog.Info("starting firehose consumer", "relayHost", relayHost) 201 - return events.HandleRepoStream(ctx, con, scheduler, nil) 202 - } 203 - 204 - func (gfc *GoatFirehoseConsumer) handleIdentityEvent(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Identity) error { 205 - if gfc.VerifySig { 206 - did, err := syntax.ParseDID(evt.Did) 207 - if err != nil { 208 - return err 209 - } 210 - gfc.Dir.Purge(ctx, did.AtIdentifier()) 211 - } 212 - if gfc.VerifyBasic { 213 - if _, err := syntax.ParseDID(evt.Did); err != nil { 214 - slog.Warn("invalid DID", "eventType", "identity", "did", evt.Did, "seq", evt.Seq) 215 - } 216 - } 217 - if gfc.Quiet { 218 - return nil 219 - } 220 - out := make(map[string]interface{}) 221 - out["type"] = "identity" 222 - out["payload"] = evt 223 - b, err := json.Marshal(out) 224 - if err != nil { 225 - return err 226 - } 227 - fmt.Println(string(b)) 228 - return nil 229 - } 230 - 231 - func (gfc *GoatFirehoseConsumer) handleAccountEvent(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Account) error { 232 - if gfc.VerifyBasic { 233 - if _, err := syntax.ParseDID(evt.Did); err != nil { 234 - slog.Warn("invalid DID", "eventType", "account", "did", evt.Did, "seq", evt.Seq) 235 - } 236 - } 237 - if gfc.Quiet { 238 - return nil 239 - } 240 - out := make(map[string]interface{}) 241 - out["type"] = "account" 242 - out["payload"] = evt 243 - b, err := json.Marshal(out) 244 - if err != nil { 245 - return err 246 - } 247 - fmt.Println(string(b)) 248 - return nil 249 - } 250 - 251 - func (gfc *GoatFirehoseConsumer) handleSyncEvent(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Sync) error { 252 - commit, _, err := repo.LoadCommitFromCAR(ctx, bytes.NewReader(evt.Blocks)) 253 - if err != nil { 254 - return err 255 - } 256 - if gfc.VerifyBasic { 257 - if err := commit.VerifyStructure(); err != nil { 258 - slog.Warn("bad commit object", "eventType", "sync", "did", evt.Did, "seq", evt.Seq, "err", err) 259 - } 260 - if _, err := syntax.ParseDID(evt.Did); err != nil { 261 - slog.Warn("invalid DID", "eventType", "account", "did", evt.Did, "seq", evt.Seq) 262 - } 263 - } 264 - if gfc.Quiet { 265 - return nil 266 - } 267 - if !gfc.Blocks { 268 - evt.Blocks = nil 269 - } 270 - out := make(map[string]interface{}) 271 - out["type"] = "sync" 272 - out["commit"] = commit.AsData() // NOTE: funky, but helpful, to include this in output 273 - out["payload"] = evt 274 - b, err := json.Marshal(out) 275 - if err != nil { 276 - return err 277 - } 278 - fmt.Println(string(b)) 279 - return nil 280 - } 281 - 282 - // this is the simple version, when not in "records" mode: print the event as JSON, but don't include blocks 283 - func (gfc *GoatFirehoseConsumer) handleCommitEvent(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Commit) error { 284 - 285 - if gfc.VerifyBasic || gfc.VerifySig || gfc.VerifyMST { 286 - 287 - logger := slog.With("eventType", "commit", "did", evt.Repo, "seq", evt.Seq, "rev", evt.Rev) 288 - 289 - did, err := syntax.ParseDID(evt.Repo) 290 - if err != nil { 291 - return err 292 - } 293 - 294 - commit, _, err := repo.LoadCommitFromCAR(ctx, bytes.NewReader(evt.Blocks)) 295 - if err != nil { 296 - return err 297 - } 298 - 299 - if gfc.VerifySig { 300 - ident, err := gfc.Dir.LookupDID(ctx, did) 301 - if err != nil { 302 - return err 303 - } 304 - pubkey, err := ident.PublicKey() 305 - if err != nil { 306 - return err 307 - } 308 - logger = logger.With("pds", ident.PDSEndpoint()) 309 - if err := commit.VerifySignature(pubkey); err != nil { 310 - logger.Warn("commit signature validation failed", "err", err) 311 - } 312 - } 313 - 314 - if len(evt.Blocks) == 0 { 315 - logger.Warn("commit message missing blocks") 316 - } 317 - 318 - if gfc.VerifyBasic { 319 - // the commit itself 320 - if err := commit.VerifyStructure(); err != nil { 321 - logger.Warn("bad commit object", "err", err) 322 - } 323 - // the event fields 324 - rev, err := syntax.ParseTID(evt.Rev) 325 - if err != nil { 326 - logger.Warn("bad TID syntax in commit rev", "err", err) 327 - } 328 - if rev.String() != commit.Rev { 329 - logger.Warn("event rev != commit rev", "commitRev", commit.Rev) 330 - } 331 - if did.String() != commit.DID { 332 - logger.Warn("event DID != commit DID", "commitDID", commit.DID) 333 - } 334 - _, err = syntax.ParseDatetime(evt.Time) 335 - if err != nil { 336 - logger.Warn("bad datetime syntax in commit time", "time", evt.Time, "err", err) 337 - } 338 - if evt.TooBig { 339 - logger.Warn("deprecated tooBig commit flag set") 340 - } 341 - if evt.Rebase { 342 - logger.Warn("deprecated rebase commit flag set") 343 - } 344 - } 345 - 346 - if gfc.VerifyMST { 347 - if evt.PrevData == nil { 348 - logger.Warn("prevData is nil, skipping MST check") 349 - } else { 350 - // TODO: break out this function in to smaller chunks 351 - if _, err := repo.VerifyCommitMessage(ctx, evt); err != nil { 352 - logger.Warn("failed to invert commit MST", "err", err) 353 - } 354 - } 355 - } 356 - } 357 - 358 - if gfc.Quiet { 359 - return nil 360 - } 361 - 362 - // apply collections filter 363 - if len(gfc.CollectionFilter) > 0 { 364 - keep := false 365 - for _, op := range evt.Ops { 366 - parts := strings.SplitN(op.Path, "/", 3) 367 - if len(parts) != 2 { 368 - slog.Error("invalid record path", "path", op.Path) 369 - return nil 370 - } 371 - collection := parts[0] 372 - for _, c := range gfc.CollectionFilter { 373 - if c == collection { 374 - keep = true 375 - break 376 - } 377 - } 378 - if keep == true { 379 - break 380 - } 381 - } 382 - if !keep { 383 - return nil 384 - } 385 - } 386 - 387 - if !gfc.Blocks { 388 - evt.Blocks = nil 389 - } 390 - out := make(map[string]interface{}) 391 - out["type"] = "commit" 392 - out["payload"] = evt 393 - b, err := json.Marshal(out) 394 - if err != nil { 395 - return err 396 - } 397 - fmt.Println(string(b)) 398 - return nil 399 - } 400 - 401 - func (gfc *GoatFirehoseConsumer) handleCommitEventOps(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Commit) error { 402 - logger := slog.With("event", "commit", "did", evt.Repo, "rev", evt.Rev, "seq", evt.Seq) 403 - 404 - if evt.TooBig { 405 - logger.Warn("skipping tooBig events for now") 406 - return nil 407 - } 408 - 409 - _, rr, err := repo.LoadRepoFromCAR(ctx, bytes.NewReader(evt.Blocks)) 410 - if err != nil { 411 - logger.Error("failed to read repo from car", "err", err) 412 - return nil 413 - } 414 - 415 - for _, op := range evt.Ops { 416 - collection, rkey, err := syntax.ParseRepoPath(op.Path) 417 - if err != nil { 418 - logger.Error("invalid path in repo op", "eventKind", op.Action, "path", op.Path) 419 - return nil 420 - } 421 - logger = logger.With("eventKind", op.Action, "collection", collection, "rkey", rkey) 422 - 423 - if len(gfc.CollectionFilter) > 0 { 424 - keep := false 425 - for _, c := range gfc.CollectionFilter { 426 - if collection.String() == c { 427 - keep = true 428 - break 429 - } 430 - } 431 - if keep == false { 432 - continue 433 - } 434 - } 435 - 436 - out := make(map[string]interface{}) 437 - out["seq"] = evt.Seq 438 - out["rev"] = evt.Rev 439 - out["time"] = evt.Time 440 - out["collection"] = collection 441 - out["rkey"] = rkey 442 - 443 - switch op.Action { 444 - case "create", "update": 445 - coll, rkey, err := syntax.ParseRepoPath(op.Path) 446 - if err != nil { 447 - return err 448 - } 449 - // read the record bytes from blocks, and verify CID 450 - recBytes, rc, err := rr.GetRecordBytes(ctx, coll, rkey) 451 - if err != nil { 452 - logger.Error("reading record from event blocks (CAR)", "err", err) 453 - break 454 - } 455 - if op.Cid == nil || lexutil.LexLink(*rc) != *op.Cid { 456 - logger.Error("mismatch between commit op CID and record block", "recordCID", rc, "opCID", op.Cid) 457 - break 458 - } 459 - 460 - out["action"] = op.Action 461 - d, err := data.UnmarshalCBOR(recBytes) 462 - if err != nil { 463 - slog.Warn("failed to parse record CBOR") 464 - continue 465 - } 466 - out["cid"] = op.Cid.String() 467 - out["record"] = d 468 - b, err := json.Marshal(out) 469 - if err != nil { 470 - return err 471 - } 472 - if !gfc.Quiet { 473 - fmt.Println(string(b)) 474 - } 475 - case "delete": 476 - out["action"] = "delete" 477 - b, err := json.Marshal(out) 478 - if err != nil { 479 - return err 480 - } 481 - if !gfc.Quiet { 482 - fmt.Println(string(b)) 483 - } 484 - default: 485 - logger.Error("unexpected record op kind") 486 - } 487 - } 488 - return nil 489 - }
-93
cmd/goat/identity.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - 8 - "github.com/bluesky-social/indigo/atproto/identity" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 - "github.com/urfave/cli/v2" 12 - ) 13 - 14 - var cmdResolve = &cli.Command{ 15 - Name: "resolve", 16 - Usage: "lookup identity metadata", 17 - ArgsUsage: `<at-identifier>`, 18 - Flags: []cli.Flag{ 19 - &cli.BoolFlag{ 20 - Name: "did", 21 - Usage: "just resolve to DID", 22 - }, 23 - }, 24 - Action: runResolve, 25 - } 26 - 27 - func runResolve(cctx *cli.Context) error { 28 - ctx := context.Background() 29 - s := cctx.Args().First() 30 - if s == "" { 31 - return fmt.Errorf("need to provide account identifier as an argument") 32 - } 33 - 34 - atid, err := syntax.ParseAtIdentifier(s) 35 - if err != nil { 36 - return err 37 - } 38 - dir := identity.BaseDirectory{} 39 - var raw json.RawMessage 40 - 41 - if atid.IsDID() { 42 - did, err := atid.AsDID() 43 - if err != nil { 44 - return err 45 - } 46 - if cctx.Bool("did") { 47 - fmt.Println(did) 48 - return nil 49 - } 50 - raw, err = dir.ResolveDIDRaw(ctx, did) 51 - if err != nil { 52 - return err 53 - } 54 - } else { 55 - handle, err := atid.AsHandle() 56 - if err != nil { 57 - return err 58 - } 59 - did, err := dir.ResolveHandle(ctx, handle) 60 - if err != nil { 61 - return err 62 - } 63 - if cctx.Bool("did") { 64 - fmt.Println(did) 65 - return nil 66 - } 67 - raw, err = dir.ResolveDIDRaw(ctx, did) 68 - if err != nil { 69 - return err 70 - } 71 - 72 - var doc identity.DIDDocument 73 - if err := json.Unmarshal(raw, &doc); err != nil { 74 - return err 75 - } 76 - ident := identity.ParseIdentity(&doc) 77 - decl, err := ident.DeclaredHandle() 78 - if err != nil { 79 - return err 80 - } 81 - if handle != decl { 82 - return fmt.Errorf("invalid handle") 83 - } 84 - } 85 - 86 - b, err := json.MarshalIndent(raw, "", " ") 87 - if err != nil { 88 - return err 89 - } 90 - 91 - fmt.Println(string(b)) 92 - return nil 93 - }
-124
cmd/goat/key.go
··· 1 - package main 2 - 3 - import ( 4 - "fmt" 5 - 6 - "github.com/bluesky-social/indigo/atproto/crypto" 7 - 8 - "github.com/urfave/cli/v2" 9 - ) 10 - 11 - var cmdKey = &cli.Command{ 12 - Name: "key", 13 - Usage: "sub-commands for cryptographic keys", 14 - Subcommands: []*cli.Command{ 15 - &cli.Command{ 16 - Name: "generate", 17 - Usage: "outputs a new secret key", 18 - Flags: []cli.Flag{ 19 - &cli.StringFlag{ 20 - Name: "type", 21 - Aliases: []string{"t"}, 22 - Usage: "indicate curve type (P-256 is default)", 23 - }, 24 - &cli.BoolFlag{ 25 - Name: "terse", 26 - Usage: "print just the secret key, in multikey format", 27 - }, 28 - }, 29 - Action: runKeyGenerate, 30 - }, 31 - &cli.Command{ 32 - Name: "inspect", 33 - Usage: "parses and outputs metadata about a public or secret key", 34 - ArgsUsage: `<key>`, 35 - Action: runKeyInspect, 36 - }, 37 - }, 38 - } 39 - 40 - func runKeyGenerate(cctx *cli.Context) error { 41 - var priv crypto.PrivateKey 42 - var privMultibase string 43 - switch cctx.String("type") { 44 - case "", "P-256", "p256", "ES256", "secp256r1": 45 - sec, err := crypto.GeneratePrivateKeyP256() 46 - if err != nil { 47 - return err 48 - } 49 - privMultibase = sec.Multibase() 50 - priv = sec 51 - case "K-256", "k256", "ES256K", "secp256k1": 52 - sec, err := crypto.GeneratePrivateKeyK256() 53 - if err != nil { 54 - return err 55 - } 56 - privMultibase = sec.Multibase() 57 - priv = sec 58 - default: 59 - return fmt.Errorf("unknown key type: %s", cctx.String("type")) 60 - } 61 - if cctx.Bool("terse") { 62 - fmt.Println(privMultibase) 63 - return nil 64 - } 65 - pub, err := priv.PublicKey() 66 - if err != nil { 67 - return err 68 - } 69 - fmt.Printf("Key Type: %s\n", descKeyType(priv)) 70 - fmt.Printf("Secret Key (Multibase Syntax): save this securely (eg, add to password manager)\n\t%s\n", privMultibase) 71 - fmt.Printf("Public Key (DID Key Syntax): share or publish this (eg, in DID document)\n\t%s\n", pub.DIDKey()) 72 - return nil 73 - } 74 - 75 - func descKeyType(val interface{}) string { 76 - switch val.(type) { 77 - case *crypto.PublicKeyP256, crypto.PublicKeyP256: 78 - return "P-256 / secp256r1 / ES256 public key" 79 - case *crypto.PrivateKeyP256, crypto.PrivateKeyP256: 80 - return "P-256 / secp256r1 / ES256 private key" 81 - case *crypto.PublicKeyK256, crypto.PublicKeyK256: 82 - return "K-256 / secp256k1 / ES256K public key" 83 - case *crypto.PrivateKeyK256, crypto.PrivateKeyK256: 84 - return "K-256 / secp256k1 / ES256K private key" 85 - default: 86 - return "unknown" 87 - } 88 - } 89 - 90 - func runKeyInspect(cctx *cli.Context) error { 91 - s := cctx.Args().First() 92 - if s == "" { 93 - return fmt.Errorf("need to provide key as an argument") 94 - } 95 - 96 - sec, err := crypto.ParsePrivateMultibase(s) 97 - if nil == err { 98 - fmt.Printf("Type: %s\n", descKeyType(sec)) 99 - fmt.Printf("Encoding: multibase\n") 100 - pub, err := sec.PublicKey() 101 - if err != nil { 102 - return err 103 - } 104 - fmt.Printf("Public (DID Key): %s\n", pub.DIDKey()) 105 - return nil 106 - } 107 - 108 - pub, err := crypto.ParsePublicMultibase(s) 109 - if nil == err { 110 - fmt.Printf("Type: %s\n", descKeyType(pub)) 111 - fmt.Printf("Encoding: multibase\n") 112 - fmt.Printf("As DID Key: %s\n", pub.DIDKey()) 113 - return nil 114 - } 115 - 116 - pub, err = crypto.ParsePublicDIDKey(s) 117 - if nil == err { 118 - fmt.Printf("Type: %s\n", descKeyType(pub)) 119 - fmt.Printf("Encoding: DID Key\n") 120 - fmt.Printf("As Multibase: %s\n", pub.Multibase()) 121 - return nil 122 - } 123 - return fmt.Errorf("unknown key encoding or type") 124 - }
-342
cmd/goat/lexicon.go
··· 1 - package main 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "io" 7 - "os" 8 - "strings" 9 - 10 - "github.com/bluesky-social/indigo/api/agnostic" 11 - "github.com/bluesky-social/indigo/atproto/data" 12 - "github.com/bluesky-social/indigo/atproto/identity" 13 - "github.com/bluesky-social/indigo/atproto/lexicon" 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - "github.com/bluesky-social/indigo/xrpc" 16 - 17 - "github.com/urfave/cli/v2" 18 - ) 19 - 20 - var cmdLex = &cli.Command{ 21 - Name: "lex", 22 - Usage: "sub-commands for Lexicons", 23 - Flags: []cli.Flag{}, 24 - Subcommands: []*cli.Command{ 25 - &cli.Command{ 26 - Name: "resolve", 27 - Usage: "lookup a schema for an NSID", 28 - ArgsUsage: `<nsid>`, 29 - Flags: []cli.Flag{ 30 - &cli.BoolFlag{ 31 - Name: "did", 32 - Usage: "just resolve to DID, not the schema itself", 33 - }, 34 - }, 35 - Action: runLexResolve, 36 - }, 37 - &cli.Command{ 38 - Name: "parse", 39 - Usage: "parse and validate Lexicon schema files", 40 - ArgsUsage: `<path>+`, 41 - Flags: []cli.Flag{}, 42 - Action: runLexParse, 43 - }, 44 - &cli.Command{ 45 - Name: "publish", 46 - Usage: "add schema JSON files to atproto repo", 47 - ArgsUsage: `<path>+`, 48 - Flags: []cli.Flag{}, 49 - Action: runLexPublish, 50 - }, 51 - &cli.Command{ 52 - Name: "ls", 53 - Aliases: []string{"list"}, 54 - Usage: "list all known Lexicon NSIDs at the same level of hierarchy", 55 - ArgsUsage: `<nsid>`, 56 - Flags: []cli.Flag{}, 57 - Action: runLexList, 58 - }, 59 - &cli.Command{ 60 - Name: "validate", 61 - Usage: "validate a record, either AT-URI or local file", 62 - ArgsUsage: `<uri-or-path>`, 63 - Flags: []cli.Flag{ 64 - &cli.BoolFlag{ 65 - Name: "allow-legacy-blob", 66 - Usage: "be permissive of legacy blobs", 67 - }, 68 - &cli.StringFlag{ 69 - Name: "catalog", 70 - Aliases: []string{"c"}, 71 - Usage: "path to directory of Lexicon files", 72 - }, 73 - }, 74 - Action: runLexValidate, 75 - }, 76 - }, 77 - } 78 - 79 - func loadSchemaFile(p string) (map[string]any, error) { 80 - f, err := os.Open(p) 81 - if err != nil { 82 - return nil, err 83 - } 84 - defer func() { _ = f.Close() }() 85 - b, err := io.ReadAll(f) 86 - if err != nil { 87 - return nil, err 88 - } 89 - 90 - // verify format 91 - var sf lexicon.SchemaFile 92 - if err := json.Unmarshal(b, &sf); err != nil { 93 - return nil, err 94 - } 95 - // TODO: additional validation? 96 - 97 - // parse as raw data 98 - d, err := data.UnmarshalJSON(b) 99 - if err != nil { 100 - return nil, err 101 - } 102 - return d, nil 103 - } 104 - 105 - func runLexParse(cctx *cli.Context) error { 106 - if cctx.Args().Len() <= 0 { 107 - return fmt.Errorf("require at least one path to parse") 108 - } 109 - for _, path := range cctx.Args().Slice() { 110 - _, err := loadSchemaFile(path) 111 - if err != nil { 112 - return fmt.Errorf("failed to parse %s: %w", path, err) 113 - } 114 - fmt.Printf("%s: success\n", path) 115 - } 116 - return nil 117 - } 118 - 119 - func runLexPublish(cctx *cli.Context) error { 120 - if cctx.Args().Len() <= 0 { 121 - return fmt.Errorf("require at least one path to publish") 122 - } 123 - 124 - ctx := cctx.Context 125 - xrpcc, err := loadAuthClient(ctx) 126 - if err == ErrNoAuthSession { 127 - return fmt.Errorf("auth required, but not logged in") 128 - } else if err != nil { 129 - return err 130 - } 131 - 132 - validateFlag := false 133 - 134 - for _, path := range cctx.Args().Slice() { 135 - recordVal, err := loadSchemaFile(path) 136 - if err != nil { 137 - return fmt.Errorf("failed to parse %s: %w", path, err) 138 - } 139 - 140 - recordVal["$type"] = "com.atproto.lexicon.schema" 141 - val, ok := recordVal["id"] 142 - if !ok { 143 - return fmt.Errorf("missing NSID in Lexicon schema") 144 - } 145 - rawNSID, ok := val.(string) 146 - if !ok { 147 - return fmt.Errorf("missing NSID in Lexicon schema") 148 - } 149 - nsid, err := syntax.ParseNSID(rawNSID) 150 - if err != nil { 151 - return err 152 - } 153 - nsidStr := nsid.String() 154 - 155 - resp, err := agnostic.RepoPutRecord(ctx, xrpcc, &agnostic.RepoPutRecord_Input{ 156 - Collection: "com.atproto.lexicon.schema", 157 - Repo: xrpcc.Auth.Did, 158 - Record: recordVal, 159 - Rkey: nsidStr, 160 - Validate: &validateFlag, 161 - }) 162 - if err != nil { 163 - return err 164 - } 165 - 166 - fmt.Printf("%s\t%s\n", resp.Uri, resp.Cid) 167 - } 168 - return nil 169 - } 170 - 171 - func runLexResolve(cctx *cli.Context) error { 172 - ctx := cctx.Context 173 - raw := cctx.Args().First() 174 - if raw == "" { 175 - return fmt.Errorf("NSID argument is required") 176 - } 177 - 178 - // TODO: handle fragments 179 - nsid, err := syntax.ParseNSID(raw) 180 - if err != nil { 181 - return err 182 - } 183 - 184 - dir := identity.BaseDirectory{} 185 - if cctx.Bool("did") { 186 - did, err := dir.ResolveNSID(ctx, nsid) 187 - if err != nil { 188 - return err 189 - } 190 - fmt.Println(did) 191 - return nil 192 - } 193 - 194 - data, err := lexicon.ResolveLexiconData(ctx, &dir, nsid) 195 - if err != nil { 196 - return err 197 - } 198 - 199 - b, err := json.MarshalIndent(data, "", " ") 200 - if err != nil { 201 - return err 202 - } 203 - fmt.Println(string(b)) 204 - 205 - return nil 206 - } 207 - 208 - func runLexList(cctx *cli.Context) error { 209 - ctx := cctx.Context 210 - raw := cctx.Args().First() 211 - if raw == "" { 212 - return fmt.Errorf("NSID argument is required") 213 - } 214 - 215 - // TODO: handle fragments? 216 - nsid, err := syntax.ParseNSID(raw) 217 - if err != nil { 218 - return err 219 - } 220 - authority := nsid.Authority() 221 - 222 - dir := identity.BaseDirectory{} 223 - did, err := dir.ResolveNSID(ctx, nsid) 224 - if err != nil { 225 - return err 226 - } 227 - 228 - ident, err := dir.LookupDID(ctx, did) 229 - if err != nil { 230 - return err 231 - } 232 - 233 - // create a new API client to connect to the account's PDS 234 - xrpcc := xrpc.Client{ 235 - Host: ident.PDSEndpoint(), 236 - UserAgent: userAgent(), 237 - } 238 - if xrpcc.Host == "" { 239 - return fmt.Errorf("no PDS endpoint for identity") 240 - } 241 - 242 - // iterate through all records in the lexicon schema collection, and check if prefix ("authority") matches that of the original NSID 243 - // NOTE: much of this code is copied from runRecordList 244 - cursor := "" 245 - for { 246 - // collection string, cursor string, limit int64, repo string, reverse bool 247 - resp, err := agnostic.RepoListRecords(ctx, &xrpcc, "com.atproto.lexicon.schema", cursor, 100, ident.DID.String(), false) 248 - if err != nil { 249 - return err 250 - } 251 - for _, rec := range resp.Records { 252 - aturi, err := syntax.ParseATURI(rec.Uri) 253 - if err != nil { 254 - return err 255 - } 256 - schemaNSID, err := syntax.ParseNSID(aturi.RecordKey().String()) 257 - if err != nil { 258 - continue 259 - } 260 - if schemaNSID.Authority() == authority { 261 - fmt.Println(schemaNSID) 262 - } 263 - } 264 - if resp.Cursor != nil && *resp.Cursor != "" { 265 - cursor = *resp.Cursor 266 - } else { 267 - break 268 - } 269 - } 270 - 271 - return nil 272 - } 273 - 274 - func runLexValidate(cctx *cli.Context) error { 275 - ctx := cctx.Context 276 - ref := cctx.Args().First() 277 - if ref == "" { 278 - return fmt.Errorf("URI or file path argument is required") 279 - } 280 - 281 - var nsid syntax.NSID 282 - var recordData map[string]any 283 - dir := identity.BaseDirectory{} 284 - cat := lexicon.NewResolvingCatalog() 285 - 286 - var flags lexicon.ValidateFlags = 0 287 - if cctx.Bool("allow-legacy-blob") { 288 - flags |= lexicon.AllowLegacyBlob 289 - } 290 - 291 - if cctx.String("catalog") != "" { 292 - fmt.Printf("loading catalog directory: %s\n", cctx.String("catalog")) 293 - if err := cat.Base.LoadDirectory(cctx.String("catalog")); err != nil { 294 - return err 295 - } 296 - } 297 - 298 - // fetch from network if an AT-URI 299 - if strings.HasPrefix(ref, "at://") { 300 - aturi, err := syntax.ParseATURI(ref) 301 - if err != nil { 302 - return err 303 - } 304 - nsid = aturi.Collection() 305 - 306 - ident, err := dir.Lookup(ctx, aturi.Authority()) 307 - if err != nil { 308 - return err 309 - } 310 - 311 - recordData, err = fetchRecord(ctx, *ident, aturi) 312 - if err != nil { 313 - return err 314 - } 315 - } else { 316 - // otherwise try to read from disk 317 - recordBytes, err := os.ReadFile(ref) 318 - if err != nil { 319 - return err 320 - } 321 - 322 - rawNSID, err := data.ExtractTypeJSON(recordBytes) 323 - if err != nil { 324 - return err 325 - } 326 - nsid, err = syntax.ParseNSID(rawNSID) 327 - if err != nil { 328 - return err 329 - } 330 - 331 - recordData, err = data.UnmarshalJSON(recordBytes) 332 - if err != nil { 333 - return err 334 - } 335 - } 336 - 337 - if err := lexicon.ValidateRecord(&cat, recordData, nsid.String(), flags); err != nil { 338 - return err 339 - } 340 - fmt.Printf("valid %s record\n", nsid) 341 - return nil 342 - }
-52
cmd/goat/main.go
··· 1 - package main 2 - 3 - import ( 4 - "fmt" 5 - "os" 6 - 7 - _ "github.com/joho/godotenv/autoload" 8 - 9 - "github.com/carlmjohnson/versioninfo" 10 - "github.com/urfave/cli/v2" 11 - ) 12 - 13 - func main() { 14 - if err := run(os.Args); err != nil { 15 - fmt.Fprintf(os.Stderr, "error: %v\n", err) 16 - os.Exit(-1) 17 - } 18 - } 19 - 20 - func run(args []string) error { 21 - 22 - app := cli.App{ 23 - Name: "goat", 24 - Usage: "Go AT protocol CLI tool", 25 - Version: versioninfo.Short(), 26 - Flags: []cli.Flag{ 27 - &cli.StringFlag{ 28 - Name: "log-level", 29 - Usage: "log verbosity level (eg: warn, info, debug)", 30 - EnvVars: []string{"GOAT_LOG_LEVEL", "GO_LOG_LEVEL", "LOG_LEVEL"}, 31 - }, 32 - }, 33 - } 34 - app.Commands = []*cli.Command{ 35 - cmdRecordGet, 36 - cmdRecordList, 37 - cmdFirehose, 38 - cmdResolve, 39 - cmdRepo, 40 - cmdBlob, 41 - cmdLex, 42 - cmdAccount, 43 - cmdPLC, 44 - cmdBsky, 45 - cmdRecord, 46 - cmdSyntax, 47 - cmdKey, 48 - cmdPds, 49 - cmdRelay, 50 - } 51 - return app.Run(args) 52 - }
-35
cmd/goat/net.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "log/slog" 7 - 8 - "github.com/bluesky-social/indigo/api/agnostic" 9 - "github.com/bluesky-social/indigo/atproto/data" 10 - "github.com/bluesky-social/indigo/atproto/identity" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - "github.com/bluesky-social/indigo/xrpc" 13 - ) 14 - 15 - func fetchRecord(ctx context.Context, ident identity.Identity, aturi syntax.ATURI) (map[string]any, error) { 16 - 17 - slog.Debug("fetching record", "did", ident.DID.String(), "collection", aturi.Collection().String(), "rkey", aturi.RecordKey().String()) 18 - xrpcc := xrpc.Client{ 19 - Host: ident.PDSEndpoint(), 20 - UserAgent: userAgent(), 21 - } 22 - resp, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", aturi.Collection().String(), ident.DID.String(), aturi.RecordKey().String()) 23 - if err != nil { 24 - return nil, err 25 - } 26 - 27 - if nil == resp.Value { 28 - return nil, fmt.Errorf("empty record in response") 29 - } 30 - record, err := data.UnmarshalJSON(*resp.Value) 31 - if err != nil { 32 - return nil, fmt.Errorf("fetched record was invalid data: %w", err) 33 - } 34 - return record, nil 35 - }
-56
cmd/goat/pds.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "strings" 8 - 9 - comatproto "github.com/bluesky-social/indigo/api/atproto" 10 - "github.com/bluesky-social/indigo/xrpc" 11 - 12 - "github.com/urfave/cli/v2" 13 - ) 14 - 15 - var cmdPds = &cli.Command{ 16 - Name: "pds", 17 - Usage: "sub-commands for pds hosts", 18 - Flags: []cli.Flag{}, 19 - Subcommands: []*cli.Command{ 20 - &cli.Command{ 21 - Name: "describe", 22 - Usage: "shows info about a PDS info", 23 - ArgsUsage: `<url>`, 24 - Action: runPdsDescribe, 25 - }, 26 - }, 27 - } 28 - 29 - func runPdsDescribe(cctx *cli.Context) error { 30 - ctx := context.Background() 31 - 32 - pdsHost := cctx.Args().First() 33 - if pdsHost == "" { 34 - return fmt.Errorf("need to provide new handle as argument") 35 - } 36 - if !strings.Contains(pdsHost, "://") { 37 - return fmt.Errorf("PDS host is not a url: %s", pdsHost) 38 - } 39 - client := xrpc.Client{ 40 - Host: pdsHost, 41 - UserAgent: userAgent(), 42 - } 43 - 44 - resp, err := comatproto.ServerDescribeServer(ctx, &client) 45 - if err != nil { 46 - return err 47 - } 48 - 49 - b, err := json.MarshalIndent(resp, "", " ") 50 - if err != nil { 51 - return err 52 - } 53 - fmt.Println(string(b)) 54 - 55 - return nil 56 - }
-763
cmd/goat/plc.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "io" 8 - "net/http" 9 - "net/url" 10 - "strings" 11 - "time" 12 - 13 - "github.com/bluesky-social/indigo/atproto/crypto" 14 - "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - "github.com/bluesky-social/indigo/util" 17 - 18 - "github.com/did-method-plc/go-didplc" 19 - 20 - "github.com/urfave/cli/v2" 21 - ) 22 - 23 - var cmdPLC = &cli.Command{ 24 - Name: "plc", 25 - Usage: "sub-commands for DID PLCs", 26 - Flags: []cli.Flag{ 27 - &cli.StringFlag{ 28 - Name: "plc-host", 29 - Usage: "method, hostname, and port of PLC registry", 30 - Value: "https://plc.directory", 31 - EnvVars: []string{"ATP_PLC_HOST"}, 32 - }, 33 - }, 34 - Subcommands: []*cli.Command{ 35 - &cli.Command{ 36 - Name: "history", 37 - Usage: "fetch operation log for individual DID", 38 - ArgsUsage: `<at-identifier>`, 39 - Flags: []cli.Flag{}, 40 - Action: runPLCHistory, 41 - }, 42 - &cli.Command{ 43 - Name: "data", 44 - Usage: "fetch current data (op) for individual DID", 45 - ArgsUsage: `<at-identifier>`, 46 - Flags: []cli.Flag{}, 47 - Action: runPLCData, 48 - }, 49 - &cli.Command{ 50 - Name: "dump", 51 - Usage: "output full operation log, as JSON lines", 52 - Flags: []cli.Flag{ 53 - &cli.StringFlag{ 54 - Name: "cursor", 55 - Aliases: []string{"c"}, 56 - Usage: "start at a given cursor offset (timestamp). use 'now' to start at current time", 57 - }, 58 - &cli.BoolFlag{ 59 - Name: "tail", 60 - Aliases: []string{"f"}, 61 - Usage: "continue streaming PLC ops after reaching the end of log", 62 - }, 63 - &cli.DurationFlag{ 64 - Name: "interval", 65 - Aliases: []string{"i"}, 66 - Value: 3 * time.Second, 67 - Usage: "sleep duration between batches for tail mode", 68 - }, 69 - &cli.IntFlag{ 70 - Name: "batch-size", 71 - Aliases: []string{"s"}, 72 - Value: 1000, 73 - Usage: "batch size of operations per HTTP API request", 74 - }, 75 - }, 76 - Action: runPLCDump, 77 - }, 78 - &cli.Command{ 79 - Name: "genesis", 80 - Usage: "produce an unsigned genesis operation", 81 - Flags: []cli.Flag{ 82 - &cli.StringFlag{ 83 - Name: "handle", 84 - Usage: "atproto handle", 85 - }, 86 - &cli.StringSliceFlag{ 87 - Name: "rotation-key", 88 - Usage: "rotation public key, in did:key format", 89 - }, 90 - &cli.StringFlag{ 91 - Name: "atproto-key", 92 - Usage: "atproto repo signing public key, in did:key format", 93 - }, 94 - &cli.StringFlag{ 95 - Name: "pds", 96 - Usage: "atproto PDS service URL", 97 - }, 98 - }, 99 - Action: runPLCGenesis, 100 - }, 101 - &cli.Command{ 102 - Name: "calc-did", 103 - Usage: "calculate the DID corresponding to a signed PLC operation", 104 - ArgsUsage: `<signed_genesis.json>`, 105 - Flags: []cli.Flag{}, 106 - Action: runPLCCalcDID, 107 - }, 108 - &cli.Command{ 109 - Name: "sign", 110 - Usage: "sign an operation, ready to be submitted", 111 - ArgsUsage: `<operation.json>`, 112 - Flags: []cli.Flag{ 113 - &cli.StringFlag{ 114 - Name: "plc-signing-key", 115 - Usage: "private key used to sign operation (multibase syntax)", 116 - EnvVars: []string{"PLC_SIGNING_KEY"}, 117 - }, 118 - }, 119 - Action: runPLCSign, 120 - }, 121 - &cli.Command{ 122 - Name: "submit", 123 - Usage: "submit a signed operation to the PLC directory", 124 - ArgsUsage: `<signed_operation.json>`, 125 - Flags: []cli.Flag{ 126 - &cli.BoolFlag{ 127 - Name: "genesis", 128 - Usage: "the operation is a genesis operation", 129 - }, 130 - &cli.StringFlag{ 131 - Name: "did", 132 - Usage: "the DID of the identity to update", 133 - }, 134 - }, 135 - Action: runPLCSubmit, 136 - }, 137 - &cli.Command{ 138 - Name: "update", 139 - Usage: "apply updates to a previous operation to produce a new one (but don't sign or submit it, yet)", 140 - ArgsUsage: `<DID>`, 141 - Flags: []cli.Flag{ 142 - &cli.StringFlag{ 143 - Name: "prev", 144 - Usage: "the CID of the operation to use as a base (uses most recent op if not specified)", 145 - }, 146 - &cli.StringFlag{ 147 - Name: "handle", 148 - Usage: "atproto handle", 149 - }, 150 - &cli.StringSliceFlag{ 151 - Name: "add-rotation-key", 152 - Usage: "rotation public key, in did:key format (added to front of rotationKey list)", 153 - }, 154 - &cli.StringSliceFlag{ 155 - Name: "remove-rotation-key", 156 - Usage: "rotation public key, in did:key format", 157 - }, 158 - &cli.StringFlag{ 159 - Name: "atproto-key", 160 - Usage: "atproto repo signing public key, in did:key format", 161 - }, 162 - &cli.StringFlag{ 163 - Name: "pds", 164 - Usage: "atproto PDS service URL", 165 - }, 166 - }, 167 - Action: runPLCUpdate, 168 - }, 169 - }, 170 - } 171 - 172 - func runPLCHistory(cctx *cli.Context) error { 173 - ctx := context.Background() 174 - plcHost := cctx.String("plc-host") 175 - s := cctx.Args().First() 176 - if s == "" { 177 - return fmt.Errorf("need to provide account identifier as an argument") 178 - } 179 - 180 - dir := identity.BaseDirectory{ 181 - PLCURL: plcHost, 182 - } 183 - 184 - id, err := syntax.ParseAtIdentifier(s) 185 - if err != nil { 186 - return err 187 - } 188 - var did syntax.DID 189 - if id.IsDID() { 190 - did, err = id.AsDID() 191 - if err != nil { 192 - return err 193 - } 194 - } else { 195 - hdl, err := id.AsHandle() 196 - if err != nil { 197 - return err 198 - } 199 - did, err = dir.ResolveHandle(ctx, hdl) 200 - if err != nil { 201 - return err 202 - } 203 - } 204 - 205 - if did.Method() != "plc" { 206 - return fmt.Errorf("non-PLC DID method: %s", did.Method()) 207 - } 208 - 209 - url := fmt.Sprintf("%s/%s/log", plcHost, did) 210 - resp, err := http.Get(url) 211 - if err != nil { 212 - return err 213 - } 214 - defer resp.Body.Close() 215 - if resp.StatusCode != http.StatusOK { 216 - return fmt.Errorf("PLC HTTP request failed") 217 - } 218 - respBytes, err := io.ReadAll(resp.Body) 219 - if err != nil { 220 - return err 221 - } 222 - 223 - // parse JSON and reformat for printing 224 - var oplog []map[string]interface{} 225 - err = json.Unmarshal(respBytes, &oplog) 226 - if err != nil { 227 - return err 228 - } 229 - 230 - for _, op := range oplog { 231 - b, err := json.MarshalIndent(op, "", " ") 232 - if err != nil { 233 - return err 234 - } 235 - fmt.Println(string(b)) 236 - } 237 - 238 - return nil 239 - } 240 - 241 - func runPLCData(cctx *cli.Context) error { 242 - ctx := context.Background() 243 - plcHost := cctx.String("plc-host") 244 - s := cctx.Args().First() 245 - if s == "" { 246 - return fmt.Errorf("need to provide account identifier as an argument") 247 - } 248 - 249 - dir := identity.BaseDirectory{ 250 - PLCURL: plcHost, 251 - } 252 - 253 - id, err := syntax.ParseAtIdentifier(s) 254 - if err != nil { 255 - return err 256 - } 257 - var did syntax.DID 258 - if id.IsDID() { 259 - did, err = id.AsDID() 260 - if err != nil { 261 - return err 262 - } 263 - } else { 264 - hdl, err := id.AsHandle() 265 - if err != nil { 266 - return err 267 - } 268 - did, err = dir.ResolveHandle(ctx, hdl) 269 - if err != nil { 270 - return err 271 - } 272 - } 273 - 274 - if did.Method() != "plc" { 275 - return fmt.Errorf("non-PLC DID method: %s", did.Method()) 276 - } 277 - 278 - plcData, err := fetchPLCData(ctx, plcHost, did) 279 - if err != nil { 280 - return err 281 - } 282 - 283 - b, err := json.MarshalIndent(plcData, "", " ") 284 - if err != nil { 285 - return err 286 - } 287 - fmt.Println(string(b)) 288 - return nil 289 - } 290 - 291 - func runPLCDump(cctx *cli.Context) error { 292 - ctx := context.Background() 293 - plcHost := cctx.String("plc-host") 294 - client := util.RobustHTTPClient() 295 - size := cctx.Int("batch-size") 296 - tailMode := cctx.Bool("tail") 297 - interval := cctx.Duration("interval") 298 - 299 - cursor := cctx.String("cursor") 300 - if cursor == "now" { 301 - cursor = syntax.DatetimeNow().String() 302 - } 303 - var lastCursor string 304 - 305 - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/export", plcHost), nil) 306 - if err != nil { 307 - return err 308 - } 309 - req.Header.Set("User-Agent", *userAgent()) 310 - q := req.URL.Query() 311 - q.Add("count", fmt.Sprintf("%d", size)) 312 - req.URL.RawQuery = q.Encode() 313 - 314 - for { 315 - q := req.URL.Query() 316 - if cursor != "" { 317 - q.Set("after", cursor) 318 - } 319 - req.URL.RawQuery = q.Encode() 320 - 321 - resp, err := client.Do(req) 322 - if err != nil { 323 - return err 324 - } 325 - if resp.StatusCode != http.StatusOK { 326 - return fmt.Errorf("PLC HTTP request failed status=%d", resp.StatusCode) 327 - } 328 - respBytes, err := io.ReadAll(resp.Body) 329 - if err != nil { 330 - return err 331 - } 332 - 333 - lines := strings.Split(string(respBytes), "\n") 334 - if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { 335 - if tailMode { 336 - time.Sleep(interval) 337 - continue 338 - } 339 - break 340 - } 341 - for _, l := range lines { 342 - if len(l) < 2 { 343 - break 344 - } 345 - var op map[string]interface{} 346 - err = json.Unmarshal([]byte(l), &op) 347 - if err != nil { 348 - return err 349 - } 350 - var ok bool 351 - cursor, ok = op["createdAt"].(string) 352 - if !ok { 353 - return fmt.Errorf("missing createdAt in PLC op log") 354 - } 355 - if cursor == lastCursor { 356 - continue 357 - } 358 - 359 - b, err := json.Marshal(op) 360 - if err != nil { 361 - return err 362 - } 363 - fmt.Println(string(b)) 364 - } 365 - if cursor != "" && cursor == lastCursor { 366 - if tailMode { 367 - time.Sleep(interval) 368 - continue 369 - } 370 - break 371 - } 372 - lastCursor = cursor 373 - } 374 - 375 - return nil 376 - } 377 - 378 - type PLCService struct { 379 - Type string `json:"type"` 380 - Endpoint string `json:"endpoint"` 381 - } 382 - 383 - type PLCData struct { 384 - DID string `json:"did"` 385 - VerificationMethods map[string]string `json:"verificationMethods"` 386 - RotationKeys []string `json:"rotationKeys"` 387 - AlsoKnownAs []string `json:"alsoKnownAs"` 388 - Services map[string]PLCService `json:"services"` 389 - } 390 - 391 - func fetchPLCData(ctx context.Context, plcHost string, did syntax.DID) (*PLCData, error) { 392 - 393 - if plcHost == "" { 394 - return nil, fmt.Errorf("PLC host not configured") 395 - } 396 - 397 - url := fmt.Sprintf("%s/%s/data", plcHost, did) 398 - resp, err := http.Get(url) 399 - if err != nil { 400 - return nil, err 401 - } 402 - defer resp.Body.Close() 403 - if resp.StatusCode != http.StatusOK { 404 - return nil, fmt.Errorf("PLC HTTP request failed") 405 - } 406 - respBytes, err := io.ReadAll(resp.Body) 407 - if err != nil { 408 - return nil, err 409 - } 410 - 411 - var d PLCData 412 - err = json.Unmarshal(respBytes, &d) 413 - if err != nil { 414 - return nil, err 415 - } 416 - return &d, nil 417 - } 418 - 419 - func runPLCGenesis(cctx *cli.Context) error { 420 - // TODO: helper function in didplc to make an empty op like this? 421 - services := make(map[string]didplc.OpService) 422 - verifMethods := make(map[string]string) 423 - op := didplc.RegularOp{ 424 - Type: "plc_operation", 425 - RotationKeys: []string{}, 426 - VerificationMethods: verifMethods, 427 - AlsoKnownAs: []string{}, 428 - Services: services, 429 - } 430 - 431 - for _, rotationKey := range cctx.StringSlice("rotation-key") { 432 - if _, err := crypto.ParsePublicDIDKey(rotationKey); err != nil { 433 - return err 434 - } 435 - op.RotationKeys = append(op.RotationKeys, rotationKey) 436 - } 437 - 438 - handle := cctx.String("handle") 439 - if handle != "" { 440 - parsedHandle, err := syntax.ParseHandle(strings.TrimPrefix(handle, "at://")) 441 - if err != nil { 442 - return err 443 - } 444 - parsedHandle = parsedHandle.Normalize() 445 - op.AlsoKnownAs = append(op.AlsoKnownAs, "at://"+string(parsedHandle)) 446 - } 447 - 448 - atprotoKey := cctx.String("atproto-key") 449 - if atprotoKey != "" { 450 - if _, err := crypto.ParsePublicDIDKey(atprotoKey); err != nil { 451 - return err 452 - } 453 - op.VerificationMethods["atproto"] = atprotoKey 454 - } 455 - 456 - pds := cctx.String("pds") 457 - if pds != "" { 458 - parsedUrl, err := url.Parse(pds) 459 - if err != nil { 460 - return err 461 - } 462 - if !parsedUrl.IsAbs() { 463 - return fmt.Errorf("invalid PDS URL: must be absolute") 464 - } 465 - op.Services["atproto_pds"] = didplc.OpService{ 466 - Type: "AtprotoPersonalDataServer", 467 - Endpoint: pds, 468 - } 469 - } 470 - 471 - res, err := json.MarshalIndent(op, "", " ") 472 - if err != nil { 473 - return err 474 - } 475 - fmt.Println(string(res)) 476 - 477 - return nil 478 - } 479 - 480 - func runPLCCalcDID(cctx *cli.Context) error { 481 - s := cctx.Args().First() 482 - if s == "" { 483 - return fmt.Errorf("need to provide genesis json path as input") 484 - } 485 - 486 - inputReader, err := getFileOrStdin(s) 487 - if err != nil { 488 - return err 489 - } 490 - 491 - inBytes, err := io.ReadAll(inputReader) 492 - if err != nil { 493 - return err 494 - } 495 - 496 - var enum didplc.OpEnum 497 - if err := json.Unmarshal(inBytes, &enum); err != nil { 498 - return err 499 - } 500 - op := enum.AsOperation() 501 - 502 - did, err := op.DID() // errors if op is not a signed genesis op 503 - if err != nil { 504 - return err 505 - } 506 - 507 - fmt.Println(did) 508 - 509 - return nil 510 - } 511 - 512 - func runPLCSign(cctx *cli.Context) error { 513 - s := cctx.Args().First() 514 - if s == "" { 515 - return fmt.Errorf("need to provide PLC operation json path as input") 516 - } 517 - 518 - privStr := cctx.String("plc-signing-key") 519 - if privStr == "" { 520 - return fmt.Errorf("private key must be provided (HINT: use `goat account plc` if your PDS holds the keys)") 521 - } 522 - 523 - inputReader, err := getFileOrStdin(s) 524 - if err != nil { 525 - return err 526 - } 527 - 528 - inBytes, err := io.ReadAll(inputReader) 529 - if err != nil { 530 - return err 531 - } 532 - 533 - var enum didplc.OpEnum 534 - if err := json.Unmarshal(inBytes, &enum); err != nil { 535 - return err 536 - } 537 - op := enum.AsOperation() 538 - 539 - // Note: we do not require that the op is currently unsigned. 540 - // If it's already signed, we'll re-sign it. 541 - 542 - privkey, err := crypto.ParsePrivateMultibase(privStr) 543 - if err != nil { 544 - return err 545 - } 546 - 547 - if err := op.Sign(privkey); err != nil { 548 - return err 549 - } 550 - 551 - res, err := json.MarshalIndent(op, "", " ") 552 - if err != nil { 553 - return err 554 - } 555 - fmt.Println(string(res)) 556 - 557 - return nil 558 - } 559 - 560 - func runPLCSubmit(cctx *cli.Context) error { 561 - ctx := context.Background() 562 - expectGenesis := cctx.Bool("genesis") 563 - didString := cctx.String("did") 564 - 565 - if !expectGenesis && didString == "" { 566 - return fmt.Errorf("exactly one of either --genesis or --did must be specified") 567 - } 568 - 569 - if expectGenesis && didString != "" { 570 - return fmt.Errorf("exactly one of either --genesis or --did must be specified") 571 - } 572 - 573 - s := cctx.Args().First() 574 - if s == "" { 575 - return fmt.Errorf("need to provide PLC operation json path as input") 576 - } 577 - 578 - inputReader, err := getFileOrStdin(s) 579 - if err != nil { 580 - return err 581 - } 582 - 583 - inBytes, err := io.ReadAll(inputReader) 584 - if err != nil { 585 - return err 586 - } 587 - 588 - var enum didplc.OpEnum 589 - if err := json.Unmarshal(inBytes, &enum); err != nil { 590 - return fmt.Errorf("failed decoding PLC op JSON: %w", err) 591 - } 592 - op := enum.AsOperation() 593 - 594 - if op.IsGenesis() != expectGenesis { 595 - if expectGenesis { 596 - return fmt.Errorf("expected genesis operation, but a non-genesis operation was provided") 597 - } else { 598 - return fmt.Errorf("expected non-genesis operation, but a genesis operation was provided") 599 - } 600 - } 601 - 602 - if op.IsGenesis() { 603 - didString, err = op.DID() 604 - if err != nil { 605 - return err 606 - } 607 - } 608 - 609 - if !op.IsSigned() { 610 - return fmt.Errorf("operation must be signed") 611 - } 612 - 613 - c := didplc.Client{ 614 - DirectoryURL: cctx.String("plc-host"), 615 - UserAgent: *userAgent(), 616 - } 617 - 618 - if err = c.Submit(ctx, didString, op); err != nil { 619 - return err 620 - } 621 - 622 - fmt.Println("success") 623 - 624 - return nil 625 - } 626 - 627 - // fetch logs from /log/audit, select according to base_cid ("" means use latest), and 628 - // prepare it for updates: 629 - // - convert from legacy op format if needed (and reject tombstone ops) 630 - // - strip signature 631 - // - set `prev` to appropriate value 632 - func fetchOpForUpdate(ctx context.Context, c didplc.Client, did string, base_cid string) (*didplc.RegularOp, error) { 633 - auditlog, err := c.AuditLog(ctx, did) 634 - if err != nil { 635 - return nil, err 636 - } 637 - 638 - if err = didplc.VerifyOpLog(auditlog); err != nil { 639 - return nil, err 640 - } 641 - 642 - var baseLogEntry *didplc.LogEntry 643 - if base_cid == "" { 644 - // use most recent entry 645 - baseLogEntry = &auditlog[len(auditlog)-1] 646 - } else { 647 - // scan for the specified entry 648 - for _, entry := range auditlog { 649 - if entry.CID == base_cid { 650 - baseLogEntry = &entry 651 - break 652 - } 653 - } 654 - if baseLogEntry == nil { 655 - return nil, fmt.Errorf("no operation found matching CID %s", base_cid) 656 - } 657 - } 658 - var op didplc.RegularOp 659 - switch baseOp := baseLogEntry.Operation.AsOperation().(type) { 660 - case *didplc.RegularOp: 661 - op = *baseOp 662 - op.Sig = nil 663 - case *didplc.LegacyOp: 664 - op = baseOp.RegularOp() // also strips sig 665 - case *didplc.TombstoneOp: 666 - return nil, fmt.Errorf("cannot update from a tombstone op") 667 - } 668 - op.Prev = &baseLogEntry.CID 669 - return &op, nil 670 - } 671 - 672 - func runPLCUpdate(cctx *cli.Context) error { 673 - ctx := context.Background() 674 - prevCID := cctx.String("prev") 675 - 676 - didString := cctx.Args().First() 677 - if didString == "" { 678 - return fmt.Errorf("please specify a DID to update") 679 - } 680 - 681 - c := didplc.Client{ 682 - DirectoryURL: cctx.String("plc-host"), 683 - UserAgent: *userAgent(), 684 - } 685 - op, err := fetchOpForUpdate(ctx, c, didString, prevCID) 686 - if err != nil { 687 - return err 688 - } 689 - 690 - for _, rotationKey := range cctx.StringSlice("remove-rotation-key") { 691 - if _, err := crypto.ParsePublicDIDKey(rotationKey); err != nil { 692 - return err 693 - } 694 - removeSuccess := false 695 - for idx, existingRotationKey := range op.RotationKeys { 696 - if existingRotationKey == rotationKey { 697 - op.RotationKeys = append(op.RotationKeys[:idx], op.RotationKeys[idx+1:]...) 698 - removeSuccess = true 699 - } 700 - } 701 - if !removeSuccess { 702 - return fmt.Errorf("failed remove rotation key %s, not found in array", rotationKey) 703 - } 704 - } 705 - 706 - for _, rotationKey := range cctx.StringSlice("add-rotation-key") { 707 - if _, err := crypto.ParsePublicDIDKey(rotationKey); err != nil { 708 - return err 709 - } 710 - // prepend (Note: if adding multiple rotation keys at once, they'll end up in reverse order) 711 - op.RotationKeys = append([]string{rotationKey}, op.RotationKeys...) 712 - } 713 - 714 - handle := cctx.String("handle") 715 - if handle != "" { 716 - parsedHandle, err := syntax.ParseHandle(strings.TrimPrefix(handle, "at://")) 717 - if err != nil { 718 - return err 719 - } 720 - 721 - // strip any existing at:// akas 722 - // (someone might have some non-atproto akas, we will leave them untouched, 723 - // they can manually manage those or use some other tool if needed) 724 - var akas []string 725 - for _, aka := range op.AlsoKnownAs { 726 - if !strings.HasPrefix(aka, "at://") { 727 - akas = append(akas, aka) 728 - } 729 - } 730 - op.AlsoKnownAs = append(akas, "at://"+string(parsedHandle)) 731 - } 732 - 733 - atprotoKey := cctx.String("atproto-key") 734 - if atprotoKey != "" { 735 - if _, err := crypto.ParsePublicDIDKey(atprotoKey); err != nil { 736 - return err 737 - } 738 - op.VerificationMethods["atproto"] = atprotoKey 739 - } 740 - 741 - pds := cctx.String("pds") 742 - if pds != "" { 743 - parsedUrl, err := url.Parse(pds) 744 - if err != nil { 745 - return err 746 - } 747 - if !parsedUrl.IsAbs() { 748 - return fmt.Errorf("invalid PDS URL: must be absolute") 749 - } 750 - op.Services["atproto_pds"] = didplc.OpService{ 751 - Type: "AtprotoPersonalDataServer", 752 - Endpoint: pds, 753 - } 754 - } 755 - 756 - res, err := json.MarshalIndent(op, "", " ") 757 - if err != nil { 758 - return err 759 - } 760 - fmt.Println(string(res)) 761 - 762 - return nil 763 - }
-344
cmd/goat/record.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "os" 8 - 9 - "github.com/bluesky-social/indigo/api/agnostic" 10 - comatproto "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/atproto/data" 12 - "github.com/bluesky-social/indigo/atproto/identity" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "github.com/bluesky-social/indigo/xrpc" 15 - 16 - "github.com/urfave/cli/v2" 17 - ) 18 - 19 - var cmdRecord = &cli.Command{ 20 - Name: "record", 21 - Usage: "sub-commands for repo records", 22 - Flags: []cli.Flag{}, 23 - Subcommands: []*cli.Command{ 24 - cmdRecordGet, 25 - cmdRecordList, 26 - &cli.Command{ 27 - Name: "create", 28 - Usage: "create record from JSON", 29 - ArgsUsage: `<file>`, 30 - Flags: []cli.Flag{ 31 - &cli.StringFlag{ 32 - Name: "rkey", 33 - Aliases: []string{"r"}, 34 - Usage: "record key", 35 - }, 36 - &cli.BoolFlag{ 37 - Name: "no-validate", 38 - Aliases: []string{"n"}, 39 - Usage: "tells PDS not to validate record Lexicon schema", 40 - }, 41 - }, 42 - Action: runRecordCreate, 43 - }, 44 - &cli.Command{ 45 - Name: "update", 46 - Usage: "replace existing record from JSON", 47 - ArgsUsage: `<file>`, 48 - Flags: []cli.Flag{ 49 - &cli.StringFlag{ 50 - Name: "rkey", 51 - Aliases: []string{"r"}, 52 - Required: true, 53 - Usage: "record key", 54 - }, 55 - &cli.BoolFlag{ 56 - Name: "no-validate", 57 - Aliases: []string{"n"}, 58 - Usage: "tells PDS not to validate record Lexicon schema", 59 - }, 60 - }, 61 - Action: runRecordUpdate, 62 - }, 63 - &cli.Command{ 64 - Name: "delete", 65 - Usage: "delete an existing record", 66 - Flags: []cli.Flag{ 67 - &cli.StringFlag{ 68 - Name: "collection", 69 - Aliases: []string{"c"}, 70 - Required: true, 71 - Usage: "collection (NSID)", 72 - }, 73 - &cli.StringFlag{ 74 - Name: "rkey", 75 - Aliases: []string{"r"}, 76 - Required: true, 77 - Usage: "record key", 78 - }, 79 - }, 80 - Action: runRecordDelete, 81 - }, 82 - }, 83 - } 84 - 85 - var cmdRecordGet = &cli.Command{ 86 - Name: "get", 87 - Usage: "fetch record from the network", 88 - ArgsUsage: `<at-uri>`, 89 - Flags: []cli.Flag{}, 90 - Action: runRecordGet, 91 - } 92 - 93 - var cmdRecordList = &cli.Command{ 94 - Name: "ls", 95 - Aliases: []string{"list"}, 96 - Usage: "list all records for an account", 97 - ArgsUsage: `<at-identifier>`, 98 - Flags: []cli.Flag{ 99 - &cli.StringFlag{ 100 - Name: "collection", 101 - Usage: "only list records from a specific collection", 102 - }, 103 - &cli.BoolFlag{ 104 - Name: "collections", 105 - Aliases: []string{"c"}, 106 - Usage: "list collections, not individual record paths", 107 - }, 108 - }, 109 - Action: runRecordList, 110 - } 111 - 112 - func runRecordGet(cctx *cli.Context) error { 113 - ctx := context.Background() 114 - dir := identity.DefaultDirectory() 115 - 116 - uriArg := cctx.Args().First() 117 - if uriArg == "" { 118 - return fmt.Errorf("expected a single AT-URI argument") 119 - } 120 - 121 - aturi, err := syntax.ParseATURI(uriArg) 122 - if err != nil { 123 - return fmt.Errorf("not a valid AT-URI: %v", err) 124 - } 125 - ident, err := dir.Lookup(ctx, aturi.Authority()) 126 - if err != nil { 127 - return err 128 - } 129 - 130 - record, err := fetchRecord(ctx, *ident, aturi) 131 - if err != nil { 132 - return err 133 - } 134 - 135 - b, err := json.MarshalIndent(record, "", " ") 136 - if err != nil { 137 - return err 138 - } 139 - 140 - fmt.Println(string(b)) 141 - return nil 142 - } 143 - 144 - func runRecordList(cctx *cli.Context) error { 145 - ctx := context.Background() 146 - username := cctx.Args().First() 147 - if username == "" { 148 - return fmt.Errorf("need to provide username as an argument") 149 - } 150 - ident, err := resolveIdent(ctx, username) 151 - if err != nil { 152 - return err 153 - } 154 - 155 - // create a new API client to connect to the account's PDS 156 - xrpcc := xrpc.Client{ 157 - Host: ident.PDSEndpoint(), 158 - UserAgent: userAgent(), 159 - } 160 - if xrpcc.Host == "" { 161 - return fmt.Errorf("no PDS endpoint for identity") 162 - } 163 - 164 - desc, err := comatproto.RepoDescribeRepo(ctx, &xrpcc, ident.DID.String()) 165 - if err != nil { 166 - return err 167 - } 168 - if cctx.Bool("collections") { 169 - for _, nsid := range desc.Collections { 170 - fmt.Printf("%s\n", nsid) 171 - } 172 - return nil 173 - } 174 - collections := desc.Collections 175 - filter := cctx.String("collection") 176 - if filter != "" { 177 - collections = []string{filter} 178 - } 179 - 180 - for _, nsid := range collections { 181 - cursor := "" 182 - for { 183 - // collection string, cursor string, limit int64, repo string, reverse bool 184 - resp, err := agnostic.RepoListRecords(ctx, &xrpcc, nsid, cursor, 100, ident.DID.String(), false) 185 - if err != nil { 186 - return err 187 - } 188 - for _, rec := range resp.Records { 189 - aturi, err := syntax.ParseATURI(rec.Uri) 190 - if err != nil { 191 - return err 192 - } 193 - fmt.Printf("%s\t%s\t%s\n", aturi.Collection(), aturi.RecordKey(), rec.Cid) 194 - } 195 - if resp.Cursor != nil && *resp.Cursor != "" { 196 - cursor = *resp.Cursor 197 - } else { 198 - break 199 - } 200 - } 201 - } 202 - 203 - return nil 204 - } 205 - 206 - func runRecordCreate(cctx *cli.Context) error { 207 - ctx := context.Background() 208 - recordPath := cctx.Args().First() 209 - if recordPath == "" { 210 - return fmt.Errorf("need to provide file path as an argument") 211 - } 212 - 213 - xrpcc, err := loadAuthClient(ctx) 214 - if err == ErrNoAuthSession { 215 - return fmt.Errorf("auth required, but not logged in") 216 - } else if err != nil { 217 - return err 218 - } 219 - 220 - recordBytes, err := os.ReadFile(recordPath) 221 - if err != nil { 222 - return err 223 - } 224 - 225 - recordVal, err := data.UnmarshalJSON(recordBytes) 226 - if err != nil { 227 - return err 228 - } 229 - 230 - nsid, err := data.ExtractTypeJSON(recordBytes) 231 - if err != nil { 232 - return err 233 - } 234 - 235 - var rkey *string 236 - if cctx.String("rkey") != "" { 237 - rk, err := syntax.ParseRecordKey(cctx.String("rkey")) 238 - if err != nil { 239 - return err 240 - } 241 - s := rk.String() 242 - rkey = &s 243 - } 244 - validate := !cctx.Bool("no-validate") 245 - 246 - resp, err := agnostic.RepoCreateRecord(ctx, xrpcc, &agnostic.RepoCreateRecord_Input{ 247 - Collection: nsid, 248 - Repo: xrpcc.Auth.Did, 249 - Record: recordVal, 250 - Rkey: rkey, 251 - Validate: &validate, 252 - }) 253 - if err != nil { 254 - return err 255 - } 256 - 257 - fmt.Printf("%s\t%s\n", resp.Uri, resp.Cid) 258 - return nil 259 - } 260 - 261 - func runRecordUpdate(cctx *cli.Context) error { 262 - ctx := context.Background() 263 - recordPath := cctx.Args().First() 264 - if recordPath == "" { 265 - return fmt.Errorf("need to provide file path as an argument") 266 - } 267 - 268 - xrpcc, err := loadAuthClient(ctx) 269 - if err == ErrNoAuthSession { 270 - return fmt.Errorf("auth required, but not logged in") 271 - } else if err != nil { 272 - return err 273 - } 274 - 275 - recordBytes, err := os.ReadFile(recordPath) 276 - if err != nil { 277 - return err 278 - } 279 - 280 - recordVal, err := data.UnmarshalJSON(recordBytes) 281 - if err != nil { 282 - return err 283 - } 284 - 285 - nsid, err := data.ExtractTypeJSON(recordBytes) 286 - if err != nil { 287 - return err 288 - } 289 - 290 - rkey := cctx.String("rkey") 291 - 292 - // NOTE: need to fetch existing record CID to perform swap. this is optional in theory, but golang can't deal with "optional" and "nullable", so we always need to set this (?) 293 - existing, err := agnostic.RepoGetRecord(ctx, xrpcc, "", nsid, xrpcc.Auth.Did, rkey) 294 - if err != nil { 295 - return err 296 - } 297 - 298 - validate := !cctx.Bool("no-validate") 299 - 300 - resp, err := agnostic.RepoPutRecord(ctx, xrpcc, &agnostic.RepoPutRecord_Input{ 301 - Collection: nsid, 302 - Repo: xrpcc.Auth.Did, 303 - Record: recordVal, 304 - Rkey: rkey, 305 - Validate: &validate, 306 - SwapRecord: existing.Cid, 307 - }) 308 - if err != nil { 309 - return err 310 - } 311 - 312 - fmt.Printf("%s\t%s\n", resp.Uri, resp.Cid) 313 - return nil 314 - } 315 - 316 - func runRecordDelete(cctx *cli.Context) error { 317 - ctx := context.Background() 318 - 319 - xrpcc, err := loadAuthClient(ctx) 320 - if err == ErrNoAuthSession { 321 - return fmt.Errorf("auth required, but not logged in") 322 - } else if err != nil { 323 - return err 324 - } 325 - 326 - rkey, err := syntax.ParseRecordKey(cctx.String("rkey")) 327 - if err != nil { 328 - return err 329 - } 330 - collection, err := syntax.ParseNSID(cctx.String("collection")) 331 - if err != nil { 332 - return err 333 - } 334 - 335 - _, err = comatproto.RepoDeleteRecord(ctx, xrpcc, &comatproto.RepoDeleteRecord_Input{ 336 - Collection: collection.String(), 337 - Repo: xrpcc.Auth.Did, 338 - Rkey: rkey.String(), 339 - }) 340 - if err != nil { 341 - return err 342 - } 343 - return nil 344 - }
-475
cmd/goat/relay.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "sort" 8 - 9 - comatproto "github.com/bluesky-social/indigo/api/atproto" 10 - "github.com/bluesky-social/indigo/atproto/syntax" 11 - "github.com/bluesky-social/indigo/xrpc" 12 - 13 - "github.com/urfave/cli/v2" 14 - ) 15 - 16 - var cmdRelay = &cli.Command{ 17 - Name: "relay", 18 - Usage: "sub-commands for relays", 19 - Flags: []cli.Flag{ 20 - &cli.StringFlag{ 21 - Name: "relay-host", 22 - Usage: "method, hostname, and port of Relay instance", 23 - Value: "https://bsky.network", 24 - EnvVars: []string{"ATP_RELAY_HOST", "RELAY_HOST"}, 25 - }, 26 - }, 27 - Subcommands: []*cli.Command{ 28 - &cli.Command{ 29 - Name: "account", 30 - Usage: "sub-commands for accounts/repos on relay", 31 - Subcommands: []*cli.Command{ 32 - &cli.Command{ 33 - Name: "list", 34 - Aliases: []string{"ls"}, 35 - Usage: "enumerate all accounts", 36 - Flags: []cli.Flag{ 37 - &cli.StringFlag{ 38 - Name: "collection", 39 - Aliases: []string{"c"}, 40 - Usage: "collection (NSID) to match", 41 - }, 42 - &cli.BoolFlag{ 43 - Name: "json", 44 - Usage: "print output as JSON lines", 45 - }, 46 - }, 47 - Action: runRelayAccountList, 48 - }, 49 - &cli.Command{ 50 - Name: "status", 51 - ArgsUsage: `<did>`, 52 - Usage: "describe status of individual account", 53 - Flags: []cli.Flag{ 54 - &cli.BoolFlag{ 55 - Name: "json", 56 - Usage: "print output as JSON", 57 - }, 58 - }, 59 - Action: runRelayAccountStatus, 60 - }, 61 - }, 62 - }, 63 - &cli.Command{ 64 - Name: "host", 65 - Usage: "sub-commands for upstream hosts (eg, PDS)", 66 - Subcommands: []*cli.Command{ 67 - &cli.Command{ 68 - Name: "request-crawl", 69 - Aliases: []string{"add"}, 70 - Usage: "request crawl of upstream host (eg, PDS)", 71 - ArgsUsage: `<hostname>`, 72 - Action: runRelayHostRequestCrawl, 73 - }, 74 - &cli.Command{ 75 - Name: "list", 76 - Aliases: []string{"ls"}, 77 - Usage: "enumerate all hosts indexed by relay", 78 - Flags: []cli.Flag{ 79 - &cli.BoolFlag{ 80 - Name: "json", 81 - Usage: "print output as JSON lines", 82 - }, 83 - }, 84 - Action: runRelayHostList, 85 - }, 86 - &cli.Command{ 87 - Name: "status", 88 - ArgsUsage: `<hostname>`, 89 - Usage: "describe status of individual host", 90 - Flags: []cli.Flag{ 91 - &cli.BoolFlag{ 92 - Name: "json", 93 - Usage: "print output as JSON", 94 - }, 95 - }, 96 - Action: runRelayHostStatus, 97 - }, 98 - &cli.Command{ 99 - Name: "diff", 100 - Usage: "compare host set (and seq) between two relay instances", 101 - ArgsUsage: `<relay-A-url> <relay-B-url>`, 102 - Flags: []cli.Flag{ 103 - &cli.BoolFlag{ 104 - Name: "verbose", 105 - Usage: "print all hosts", 106 - }, 107 - &cli.IntFlag{ 108 - Name: "seq-slop", 109 - Value: 100, 110 - Usage: "sequence delta allowed as close enough", 111 - }, 112 - }, 113 - Action: runRelayHostDiff, 114 - }, 115 - }, 116 - }, 117 - cmdRelayAdmin, 118 - }, 119 - } 120 - 121 - func runRelayAccountList(cctx *cli.Context) error { 122 - ctx := cctx.Context 123 - 124 - if cctx.Args().Len() > 0 { 125 - return fmt.Errorf("unexpected arguments") 126 - } 127 - 128 - client := xrpc.Client{ 129 - Host: cctx.String("relay-host"), 130 - UserAgent: userAgent(), 131 - } 132 - 133 - collection := cctx.String("collection") 134 - cursor := "" 135 - var size int64 = 500 136 - for { 137 - if collection != "" { 138 - resp, err := comatproto.SyncListReposByCollection(ctx, &client, collection, cursor, size) 139 - if err != nil { 140 - return err 141 - } 142 - for _, r := range resp.Repos { 143 - fmt.Println(r.Did) 144 - } 145 - 146 - if resp.Cursor == nil || *resp.Cursor == "" { 147 - break 148 - } 149 - cursor = *resp.Cursor 150 - } else { 151 - resp, err := comatproto.SyncListRepos(ctx, &client, cursor, size) 152 - if err != nil { 153 - return err 154 - } 155 - 156 - for _, r := range resp.Repos { 157 - if cctx.Bool("json") { 158 - b, err := json.Marshal(r) 159 - if err != nil { 160 - return err 161 - } 162 - fmt.Println(string(b)) 163 - } else { 164 - status := "unknown" 165 - if r.Active != nil && *r.Active { 166 - status = "active" 167 - } else if r.Status != nil { 168 - status = *r.Status 169 - } 170 - fmt.Printf("%s\t%s\t%s\n", r.Did, status, r.Rev) 171 - } 172 - } 173 - 174 - if resp.Cursor == nil || *resp.Cursor == "" { 175 - break 176 - } 177 - cursor = *resp.Cursor 178 - } 179 - } 180 - return nil 181 - } 182 - 183 - func runRelayAccountStatus(cctx *cli.Context) error { 184 - ctx := cctx.Context 185 - 186 - didStr := cctx.Args().First() 187 - if didStr == "" { 188 - return fmt.Errorf("need to provide account DID as argument") 189 - } 190 - if cctx.Args().Len() != 1 { 191 - return fmt.Errorf("unexpected arguments") 192 - } 193 - 194 - did, err := syntax.ParseDID(didStr) 195 - if err != nil { 196 - return err 197 - } 198 - 199 - client := xrpc.Client{ 200 - Host: cctx.String("relay-host"), 201 - UserAgent: userAgent(), 202 - } 203 - 204 - r, err := comatproto.SyncGetRepoStatus(ctx, &client, did.String()) 205 - if err != nil { 206 - return err 207 - } 208 - 209 - if cctx.Bool("json") { 210 - b, err := json.Marshal(r) 211 - if err != nil { 212 - return err 213 - } 214 - fmt.Println(string(b)) 215 - } else { 216 - status := "unknown" 217 - if r.Active { 218 - status = "active" 219 - } else if r.Status != nil { 220 - status = *r.Status 221 - } 222 - rev := "" 223 - if r.Rev != nil { 224 - rev = *r.Rev 225 - } 226 - fmt.Printf("%s\t%s\t%s\n", r.Did, status, rev) 227 - } 228 - 229 - return nil 230 - } 231 - 232 - func runRelayHostRequestCrawl(cctx *cli.Context) error { 233 - ctx := cctx.Context 234 - 235 - hostname := cctx.Args().First() 236 - if hostname == "" { 237 - return fmt.Errorf("need to provide hostname as argument") 238 - } 239 - if cctx.Args().Len() != 1 { 240 - return fmt.Errorf("unexpected arguments") 241 - } 242 - 243 - client := xrpc.Client{ 244 - Host: cctx.String("relay-host"), 245 - UserAgent: userAgent(), 246 - } 247 - 248 - err := comatproto.SyncRequestCrawl(ctx, &client, &comatproto.SyncRequestCrawl_Input{Hostname: hostname}) 249 - if err != nil { 250 - return err 251 - } 252 - fmt.Println("success") 253 - return nil 254 - } 255 - 256 - func runRelayHostList(cctx *cli.Context) error { 257 - ctx := cctx.Context 258 - 259 - if cctx.Args().Len() > 0 { 260 - return fmt.Errorf("unexpected arguments") 261 - } 262 - 263 - client := xrpc.Client{ 264 - Host: cctx.String("relay-host"), 265 - UserAgent: userAgent(), 266 - } 267 - 268 - cursor := "" 269 - var size int64 = 500 270 - for { 271 - resp, err := comatproto.SyncListHosts(ctx, &client, cursor, size) 272 - if err != nil { 273 - return err 274 - } 275 - 276 - for _, h := range resp.Hosts { 277 - if cctx.Bool("json") { 278 - b, err := json.Marshal(h) 279 - if err != nil { 280 - return err 281 - } 282 - fmt.Println(string(b)) 283 - } else { 284 - status := "" 285 - if h.Status != nil { 286 - status = *h.Status 287 - } 288 - count := "" 289 - if h.AccountCount != nil { 290 - count = fmt.Sprintf("%d", *h.AccountCount) 291 - } 292 - seq := "" 293 - if h.Seq != nil { 294 - seq = fmt.Sprintf("%d", *h.Seq) 295 - } 296 - fmt.Printf("%s\t%s\t%s\t%s\n", h.Hostname, status, count, seq) 297 - } 298 - } 299 - 300 - if resp.Cursor == nil || *resp.Cursor == "" { 301 - break 302 - } 303 - cursor = *resp.Cursor 304 - } 305 - return nil 306 - } 307 - 308 - func runRelayHostStatus(cctx *cli.Context) error { 309 - ctx := cctx.Context 310 - 311 - hostname := cctx.Args().First() 312 - if hostname == "" { 313 - return fmt.Errorf("need to provide hostname as argument") 314 - } 315 - if cctx.Args().Len() != 1 { 316 - return fmt.Errorf("unexpected arguments") 317 - } 318 - 319 - client := xrpc.Client{ 320 - Host: cctx.String("relay-host"), 321 - UserAgent: userAgent(), 322 - } 323 - 324 - h, err := comatproto.SyncGetHostStatus(ctx, &client, hostname) 325 - if err != nil { 326 - return err 327 - } 328 - 329 - if cctx.Bool("json") { 330 - b, err := json.Marshal(h) 331 - if err != nil { 332 - return err 333 - } 334 - fmt.Println(string(b)) 335 - } else { 336 - status := "" 337 - if h.Status != nil { 338 - status = *h.Status 339 - } 340 - count := "" 341 - if h.AccountCount != nil { 342 - count = fmt.Sprintf("%d", *h.AccountCount) 343 - } 344 - seq := "" 345 - if h.Seq != nil { 346 - seq = fmt.Sprintf("%d", *h.Seq) 347 - } 348 - fmt.Printf("%s\t%s\t%s\t%s\n", h.Hostname, status, count, seq) 349 - } 350 - 351 - return nil 352 - } 353 - 354 - type hostInfo struct { 355 - Hostname string 356 - Status string 357 - Seq int64 358 - } 359 - 360 - func fetchHosts(ctx context.Context, relayHost string) ([]hostInfo, error) { 361 - 362 - client := xrpc.Client{ 363 - Host: relayHost, 364 - UserAgent: userAgent(), 365 - } 366 - 367 - hosts := []hostInfo{} 368 - cursor := "" 369 - var size int64 = 500 370 - for { 371 - resp, err := comatproto.SyncListHosts(ctx, &client, cursor, size) 372 - if err != nil { 373 - return nil, err 374 - } 375 - 376 - for _, h := range resp.Hosts { 377 - if h.Status == nil || h.Seq == nil || *h.Seq <= 0 { 378 - continue 379 - } 380 - 381 - // TODO: only active or idle hosts? 382 - info := hostInfo{ 383 - Hostname: h.Hostname, 384 - Status: *h.Status, 385 - Seq: *h.Seq, 386 - } 387 - hosts = append(hosts, info) 388 - } 389 - 390 - if resp.Cursor == nil || *resp.Cursor == "" { 391 - break 392 - } 393 - cursor = *resp.Cursor 394 - } 395 - return hosts, nil 396 - } 397 - 398 - func runRelayHostDiff(cctx *cli.Context) error { 399 - ctx := cctx.Context 400 - verbose := cctx.Bool("verbose") 401 - seqSlop := cctx.Int64("seq-slop") 402 - 403 - if cctx.Args().Len() != 2 { 404 - return fmt.Errorf("expected two relay URLs are args") 405 - } 406 - 407 - urlOne := cctx.Args().Get(0) 408 - urlTwo := cctx.Args().Get(1) 409 - 410 - listOne, err := fetchHosts(ctx, urlOne) 411 - if err != nil { 412 - return err 413 - } 414 - listTwo, err := fetchHosts(ctx, urlTwo) 415 - if err != nil { 416 - return err 417 - } 418 - 419 - allHosts := make(map[string]bool) 420 - mapOne := make(map[string]hostInfo) 421 - for _, val := range listOne { 422 - allHosts[val.Hostname] = true 423 - mapOne[val.Hostname] = val 424 - } 425 - mapTwo := make(map[string]hostInfo) 426 - for _, val := range listTwo { 427 - allHosts[val.Hostname] = true 428 - mapTwo[val.Hostname] = val 429 - } 430 - 431 - names := []string{} 432 - for k, _ := range allHosts { 433 - names = append(names, k) 434 - } 435 - sort.Strings(names) 436 - 437 - for _, k := range names { 438 - one, okOne := mapOne[k] 439 - two, okTwo := mapTwo[k] 440 - if !okOne { 441 - if !verbose && two.Status != "active" { 442 - continue 443 - } 444 - fmt.Printf("%s\t\t%s/%d\tA-missing\n", k, two.Status, two.Seq) 445 - } else if !okTwo { 446 - if !verbose && one.Status != "active" { 447 - continue 448 - } 449 - fmt.Printf("%s\t%s/%d\t\tB-missing\n", k, one.Status, one.Seq) 450 - } else { 451 - status := "" 452 - if one.Status != two.Status { 453 - status = "diff-status" 454 - } else { 455 - delta := max(one.Seq, two.Seq) - min(one.Seq, two.Seq) 456 - if delta == 0 { 457 - status = "sync" 458 - if !verbose { 459 - continue 460 - } 461 - } else if delta < seqSlop { 462 - status = "nearly" 463 - if !verbose { 464 - continue 465 - } 466 - } else { 467 - status = fmt.Sprintf("delta=%d", delta) 468 - } 469 - } 470 - fmt.Printf("%s\t%s/%d\t%s/%d\t%s\n", k, one.Status, one.Seq, two.Status, two.Seq, status) 471 - } 472 - } 473 - 474 - return nil 475 - }
-451
cmd/goat/relay_admin.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "encoding/base64" 6 - "encoding/json" 7 - "fmt" 8 - "io" 9 - "log/slog" 10 - "net/http" 11 - "net/url" 12 - 13 - "github.com/urfave/cli/v2" 14 - ) 15 - 16 - var cmdRelayAdmin = &cli.Command{ 17 - Name: "admin", 18 - Usage: "sub-comands for relay administration", 19 - Flags: []cli.Flag{ 20 - &cli.StringFlag{ 21 - Name: "admin-password", 22 - Usage: "relay admin password (for Basic admin auth)", 23 - EnvVars: []string{"RELAY_ADMIN_PASSWORD", "ATP_AUTH_ADMIN_PASSWORD"}, 24 - }, 25 - &cli.StringFlag{ 26 - Name: "admin-bearer-token", 27 - Usage: "relay admin auth token (for Bearer auth)", 28 - EnvVars: []string{"RELAY_ADMIN_BEARER_TOKEN"}, 29 - }, 30 - }, 31 - Subcommands: []*cli.Command{ 32 - &cli.Command{ 33 - Name: "account", 34 - Usage: "sub-commands for managing accounts", 35 - Subcommands: []*cli.Command{ 36 - &cli.Command{ 37 - Name: "takedown", 38 - Usage: "takedown a single account on relay", 39 - Flags: []cli.Flag{ 40 - &cli.StringFlag{ 41 - Name: "collection", 42 - Aliases: []string{"c"}, 43 - Usage: "collection (NSID) to match", 44 - }, 45 - &cli.BoolFlag{ 46 - Name: "reverse", 47 - Usage: "un-takedown", 48 - }, 49 - }, 50 - Action: runRelayAdminAccountTakedown, 51 - }, 52 - &cli.Command{ 53 - Name: "list", 54 - Aliases: []string{"ls"}, 55 - Usage: "enumerate accounts (eg, takendown)", 56 - Action: runRelayAdminAccountList, 57 - }, 58 - }, 59 - }, 60 - &cli.Command{ 61 - Name: "host", 62 - Usage: "sub-commands for upstream hosts (eg, PDS)", 63 - Subcommands: []*cli.Command{ 64 - &cli.Command{ 65 - Name: "add", 66 - Usage: "request crawl of upstream host (eg, PDS)", 67 - ArgsUsage: `<hostname>`, 68 - Action: runRelayAdminHostAdd, 69 - }, 70 - &cli.Command{ 71 - Name: "block", 72 - Usage: "request crawl of upstream host (eg, PDS)", 73 - ArgsUsage: `<hostname>`, 74 - Flags: []cli.Flag{ 75 - &cli.BoolFlag{ 76 - Name: "reverse", 77 - Usage: "un-takedown", 78 - }, 79 - }, 80 - Action: runRelayAdminHostBlock, 81 - }, 82 - &cli.Command{ 83 - Name: "list", 84 - Aliases: []string{"ls"}, 85 - Usage: "enumerate hosts crawled by relay", 86 - Action: runRelayAdminHostList, 87 - }, 88 - &cli.Command{ 89 - Name: "config", 90 - Usage: "update rate-limits per host", 91 - ArgsUsage: `<hostname>`, 92 - Flags: []cli.Flag{ 93 - &cli.IntFlag{ 94 - Name: "account-limit", 95 - }, 96 - }, 97 - Action: runRelayAdminHostConfig, 98 - }, 99 - }, 100 - }, 101 - &cli.Command{ 102 - Name: "domain", 103 - Usage: "sub-commands for domain-level config", 104 - Subcommands: []*cli.Command{ 105 - &cli.Command{ 106 - Name: "ban", 107 - Usage: "ban an entire domain name from being crawled", 108 - ArgsUsage: `<domain>`, 109 - Flags: []cli.Flag{ 110 - &cli.BoolFlag{ 111 - Name: "reverse", 112 - Usage: "un-takedown", 113 - }, 114 - }, 115 - Action: runRelayAdminDomainBan, 116 - }, 117 - &cli.Command{ 118 - Name: "list", 119 - Aliases: []string{"ls"}, 120 - Usage: "enumerate domains with configs (eg, bans)", 121 - Action: runRelayAdminDomainList, 122 - }, 123 - }, 124 - }, 125 - &cli.Command{ 126 - Name: "consumer", 127 - Usage: "sub-commands for consumers", 128 - Subcommands: []*cli.Command{ 129 - &cli.Command{ 130 - Name: "list", 131 - Aliases: []string{"ls"}, 132 - Usage: "enumerate consumers", 133 - Action: runRelayAdminConsumerList, 134 - }, 135 - }, 136 - }, 137 - }, 138 - } 139 - 140 - type RelayAdminClient struct { 141 - Host string 142 - Password string 143 - BearerToken string 144 - } 145 - 146 - func (c *RelayAdminClient) Do(method, path string, params map[string]string, body map[string]any) ([]byte, error) { 147 - u, err := url.Parse(c.Host) 148 - if err != nil { 149 - return nil, err 150 - } 151 - u.Path = path 152 - q := u.Query() 153 - for k, v := range params { 154 - q.Add(k, v) 155 - } 156 - u.RawQuery = q.Encode() 157 - 158 - var buf *bytes.Buffer 159 - if body != nil { 160 - b, err := json.Marshal(body) 161 - if err != nil { 162 - return nil, err 163 - } 164 - buf = bytes.NewBuffer(b) 165 - } 166 - 167 - var req *http.Request 168 - if buf != nil { 169 - req, err = http.NewRequest(method, u.String(), buf) 170 - } else { 171 - req, err = http.NewRequest(method, u.String(), nil) 172 - } 173 - if err != nil { 174 - return nil, err 175 - } 176 - if c.Password != "" { 177 - req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:"+c.Password))) 178 - } else if c.BearerToken != "" { 179 - req.Header.Set("Authorization", "Bearer "+c.BearerToken) 180 - } 181 - req.Header.Set("User-Agent", *userAgent()) 182 - if buf != nil { 183 - req.Header.Set("Content-Type", "application/json") 184 - } 185 - 186 - resp, err := http.DefaultClient.Do(req) 187 - if err != nil { 188 - return nil, err 189 - } 190 - 191 - defer resp.Body.Close() 192 - respBytes, err := io.ReadAll(resp.Body) 193 - if err != nil { 194 - return nil, err 195 - } 196 - if resp.StatusCode != http.StatusOK { 197 - slog.Warn("relay HTTP error", "statusCode", resp.StatusCode, "body", string(respBytes)) 198 - return nil, fmt.Errorf("relay HTTP request failed: %d", resp.StatusCode) 199 - } 200 - return respBytes, nil 201 - } 202 - 203 - func NewRelayAdminClient(cctx *cli.Context) (*RelayAdminClient, error) { 204 - client := RelayAdminClient{ 205 - Host: cctx.String("relay-host"), 206 - Password: cctx.String("admin-password"), 207 - BearerToken: cctx.String("admin-bearer-token"), 208 - } 209 - if client.Password == "" && client.BearerToken == "" { 210 - return nil, fmt.Errorf("either admin password or admin bearer token must be provided") 211 - } 212 - return &client, nil 213 - } 214 - 215 - func runRelayAdminAccountTakedown(cctx *cli.Context) error { 216 - ctx := cctx.Context 217 - 218 - username := cctx.Args().First() 219 - if username == "" { 220 - return fmt.Errorf("need to provide username as an argument") 221 - } 222 - ident, err := resolveIdent(ctx, username) 223 - if err != nil { 224 - return err 225 - } 226 - 227 - client, err := NewRelayAdminClient(cctx) 228 - if err != nil { 229 - return err 230 - } 231 - 232 - path := "/admin/repo/takeDown" 233 - if cctx.Bool("reverse") { 234 - path = "/admin/repo/reverseTakedown" 235 - } 236 - 237 - body := map[string]any{ 238 - "did": ident.DID.String(), 239 - } 240 - _, err = client.Do("POST", path, nil, body) 241 - if err != nil { 242 - return err 243 - } 244 - return nil 245 - } 246 - 247 - func runRelayAdminAccountList(cctx *cli.Context) error { 248 - client, err := NewRelayAdminClient(cctx) 249 - if err != nil { 250 - return err 251 - } 252 - path := "/admin/repo/takedowns" 253 - params := map[string]string{ 254 - "cursor": "", 255 - "size": "500", 256 - } 257 - for { 258 - respBytes, err := client.Do("GET", path, params, nil) 259 - if err != nil { 260 - return err 261 - } 262 - var resp map[string]any 263 - if err := json.Unmarshal(respBytes, &resp); err != nil { 264 - return err 265 - } 266 - for _, d := range resp["dids"].([]any) { 267 - fmt.Println(d) 268 - } 269 - cursor, ok := resp["cursor"] 270 - if !ok || cursor == "" { 271 - break 272 - } 273 - params["cursor"] = cursor.(string) 274 - } 275 - return nil 276 - } 277 - 278 - func runRelayAdminHostAdd(cctx *cli.Context) error { 279 - 280 - hostname := cctx.Args().First() 281 - if hostname == "" { 282 - return fmt.Errorf("need to provide hostname as an argument") 283 - } 284 - 285 - client, err := NewRelayAdminClient(cctx) 286 - if err != nil { 287 - return err 288 - } 289 - path := "/admin/pds/requestCrawl" 290 - body := map[string]any{ 291 - "hostname": hostname, 292 - } 293 - _, err = client.Do("POST", path, nil, body) 294 - if err != nil { 295 - return err 296 - } 297 - return nil 298 - } 299 - 300 - func runRelayAdminHostBlock(cctx *cli.Context) error { 301 - 302 - hostname := cctx.Args().First() 303 - if hostname == "" { 304 - return fmt.Errorf("need to provide hostname as an argument") 305 - } 306 - 307 - client, err := NewRelayAdminClient(cctx) 308 - if err != nil { 309 - return err 310 - } 311 - 312 - path := "/admin/pds/block" 313 - if cctx.Bool("reverse") { 314 - path = "/admin/pds/unblock" 315 - } 316 - 317 - params := map[string]string{ 318 - "host": hostname, 319 - } 320 - _, err = client.Do("POST", path, params, nil) 321 - if err != nil { 322 - return err 323 - } 324 - return nil 325 - } 326 - 327 - func runRelayAdminHostList(cctx *cli.Context) error { 328 - client, err := NewRelayAdminClient(cctx) 329 - if err != nil { 330 - return err 331 - } 332 - path := "/admin/pds/list" 333 - 334 - respBytes, err := client.Do("GET", path, nil, nil) 335 - if err != nil { 336 - return err 337 - } 338 - var rows []map[string]any 339 - if err := json.Unmarshal(respBytes, &rows); err != nil { 340 - return err 341 - } 342 - for _, r := range rows { 343 - b, err := json.Marshal(r) 344 - if err != nil { 345 - return nil 346 - } 347 - fmt.Println(string(b)) 348 - } 349 - return nil 350 - } 351 - 352 - func runRelayAdminHostConfig(cctx *cli.Context) error { 353 - 354 - hostname := cctx.Args().First() 355 - if hostname == "" { 356 - return fmt.Errorf("need to provide hostname as an argument") 357 - } 358 - 359 - client, err := NewRelayAdminClient(cctx) 360 - if err != nil { 361 - return err 362 - } 363 - 364 - path := "/admin/pds/changeLimits" 365 - 366 - body := map[string]any{ 367 - "host": hostname, 368 - } 369 - if cctx.IsSet("account-limit") { 370 - body["repo_limit"] = cctx.Int("account-limit") 371 - } 372 - 373 - _, err = client.Do("POST", path, nil, body) 374 - if err != nil { 375 - return err 376 - } 377 - return nil 378 - } 379 - 380 - func runRelayAdminDomainBan(cctx *cli.Context) error { 381 - 382 - domain := cctx.Args().First() 383 - if domain == "" { 384 - return fmt.Errorf("need to provide domain as an argument") 385 - } 386 - 387 - client, err := NewRelayAdminClient(cctx) 388 - if err != nil { 389 - return err 390 - } 391 - 392 - path := "/admin/subs/banDomain" 393 - if cctx.Bool("reverse") { 394 - path = "/admin/subs/unbanDomain" 395 - } 396 - 397 - body := map[string]any{ 398 - "domain": domain, 399 - } 400 - _, err = client.Do("POST", path, nil, body) 401 - if err != nil { 402 - return err 403 - } 404 - return nil 405 - } 406 - 407 - func runRelayAdminDomainList(cctx *cli.Context) error { 408 - client, err := NewRelayAdminClient(cctx) 409 - if err != nil { 410 - return err 411 - } 412 - path := "/admin/subs/listDomainBans" 413 - 414 - respBytes, err := client.Do("GET", path, nil, nil) 415 - if err != nil { 416 - return err 417 - } 418 - var resp map[string]any 419 - if err := json.Unmarshal(respBytes, &resp); err != nil { 420 - return err 421 - } 422 - for _, d := range resp["banned_domains"].([]any) { 423 - fmt.Println(d) 424 - } 425 - return nil 426 - } 427 - 428 - func runRelayAdminConsumerList(cctx *cli.Context) error { 429 - client, err := NewRelayAdminClient(cctx) 430 - if err != nil { 431 - return err 432 - } 433 - path := "/admin/consumers/list" 434 - 435 - respBytes, err := client.Do("GET", path, nil, nil) 436 - if err != nil { 437 - return err 438 - } 439 - var rows []map[string]any 440 - if err := json.Unmarshal(respBytes, &rows); err != nil { 441 - return err 442 - } 443 - for _, r := range rows { 444 - b, err := json.Marshal(r) 445 - if err != nil { 446 - return nil 447 - } 448 - fmt.Println(string(b)) 449 - } 450 - return nil 451 - }
-323
cmd/goat/repo.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "errors" 8 - "fmt" 9 - "os" 10 - "path/filepath" 11 - "time" 12 - 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - "github.com/bluesky-social/indigo/atproto/data" 15 - "github.com/bluesky-social/indigo/atproto/repo" 16 - "github.com/bluesky-social/indigo/atproto/syntax" 17 - "github.com/bluesky-social/indigo/util" 18 - "github.com/bluesky-social/indigo/xrpc" 19 - 20 - "github.com/ipfs/go-cid" 21 - "github.com/urfave/cli/v2" 22 - ) 23 - 24 - var cmdRepo = &cli.Command{ 25 - Name: "repo", 26 - Usage: "sub-commands for repositories", 27 - Flags: []cli.Flag{}, 28 - Subcommands: []*cli.Command{ 29 - &cli.Command{ 30 - Name: "export", 31 - Usage: "download CAR file for given account", 32 - ArgsUsage: `<at-identifier>`, 33 - Flags: []cli.Flag{ 34 - &cli.StringFlag{ 35 - Name: "output", 36 - Aliases: []string{"o"}, 37 - Usage: "file path for CAR download", 38 - }, 39 - }, 40 - Action: runRepoExport, 41 - }, 42 - &cli.Command{ 43 - Name: "import", 44 - Usage: "upload CAR file for current account", 45 - ArgsUsage: `<path>`, 46 - Action: runRepoImport, 47 - }, 48 - &cli.Command{ 49 - Name: "ls", 50 - Aliases: []string{"list"}, 51 - Usage: "list records in CAR file", 52 - ArgsUsage: `<car-file>`, 53 - Flags: []cli.Flag{}, 54 - Action: runRepoList, 55 - }, 56 - &cli.Command{ 57 - Name: "inspect", 58 - Usage: "show commit metadata from CAR file", 59 - ArgsUsage: `<car-file>`, 60 - Flags: []cli.Flag{}, 61 - Action: runRepoInspect, 62 - }, 63 - &cli.Command{ 64 - Name: "mst", 65 - Usage: "show repo MST structure", 66 - ArgsUsage: `<car-file>`, 67 - Flags: []cli.Flag{ 68 - &cli.BoolFlag{ 69 - Name: "full-cid", 70 - Aliases: []string{"f"}, 71 - Usage: "display full CIDs", 72 - }, 73 - &cli.StringFlag{ 74 - Name: "root", 75 - Aliases: []string{"r"}, 76 - Usage: "CID of root block", 77 - }, 78 - }, 79 - Action: runRepoMST, 80 - }, 81 - &cli.Command{ 82 - Name: "unpack", 83 - Usage: "extract records from CAR file as directory of JSON files", 84 - ArgsUsage: `<car-file>`, 85 - Flags: []cli.Flag{ 86 - &cli.StringFlag{ 87 - Name: "output", 88 - Aliases: []string{"o"}, 89 - Usage: "directory path for unpack", 90 - }, 91 - }, 92 - Action: runRepoUnpack, 93 - }, 94 - }, 95 - } 96 - 97 - func runRepoExport(cctx *cli.Context) error { 98 - ctx := context.Background() 99 - username := cctx.Args().First() 100 - if username == "" { 101 - return fmt.Errorf("need to provide username as an argument") 102 - } 103 - ident, err := resolveIdent(ctx, username) 104 - if err != nil { 105 - return err 106 - } 107 - 108 - // create a new API client to connect to the account's PDS 109 - xrpcc := xrpc.Client{ 110 - Host: ident.PDSEndpoint(), 111 - UserAgent: userAgent(), 112 - } 113 - if xrpcc.Host == "" { 114 - return fmt.Errorf("no PDS endpoint for identity") 115 - } 116 - 117 - // set longer timeout, for large CAR files 118 - xrpcc.Client = util.RobustHTTPClient() 119 - xrpcc.Client.Timeout = 600 * time.Second 120 - 121 - carPath := cctx.String("output") 122 - if carPath == "" { 123 - // NOTE: having the rev in the the path might be nice 124 - now := time.Now().Format("20060102150405") 125 - carPath = fmt.Sprintf("%s.%s.car", username, now) 126 - } 127 - output, err := getFileOrStdout(carPath) 128 - if err != nil { 129 - if errors.Is(err, os.ErrExist) { 130 - return fmt.Errorf("file already exists: %s", carPath) 131 - } 132 - return err 133 - } 134 - defer output.Close() 135 - if carPath != stdIOPath { 136 - fmt.Printf("downloading from %s to: %s\n", xrpcc.Host, carPath) 137 - } 138 - repoBytes, err := comatproto.SyncGetRepo(ctx, &xrpcc, ident.DID.String(), "") 139 - if err != nil { 140 - return err 141 - } 142 - if _, err := output.Write(repoBytes); err != nil { 143 - return err 144 - } 145 - return nil 146 - } 147 - 148 - func runRepoImport(cctx *cli.Context) error { 149 - ctx := context.Background() 150 - 151 - carPath := cctx.Args().First() 152 - if carPath == "" { 153 - return fmt.Errorf("need to provide CAR file path as an argument") 154 - } 155 - 156 - xrpcc, err := loadAuthClient(ctx) 157 - if err == ErrNoAuthSession { 158 - return fmt.Errorf("auth required, but not logged in") 159 - } else if err != nil { 160 - return err 161 - } 162 - 163 - fileBytes, err := os.ReadFile(carPath) 164 - if err != nil { 165 - return err 166 - } 167 - 168 - err = comatproto.RepoImportRepo(ctx, xrpcc, bytes.NewReader(fileBytes)) 169 - if err != nil { 170 - return fmt.Errorf("failed to import repo: %w", err) 171 - } 172 - 173 - return nil 174 - } 175 - 176 - func runRepoList(cctx *cli.Context) error { 177 - ctx := context.Background() 178 - carPath := cctx.Args().First() 179 - if carPath == "" { 180 - return fmt.Errorf("need to provide path to CAR file as argument") 181 - } 182 - fi, err := os.Open(carPath) 183 - if err != nil { 184 - return fmt.Errorf("failed to open CAR file: %w", err) 185 - } 186 - 187 - // read repository tree in to memory 188 - _, r, err := repo.LoadRepoFromCAR(ctx, fi) 189 - if err != nil { 190 - return fmt.Errorf("failed to parse repo CAR file: %w", err) 191 - } 192 - 193 - err = r.MST.Walk(func(k []byte, v cid.Cid) error { 194 - fmt.Printf("%s\t%s\n", string(k), v.String()) 195 - return nil 196 - }) 197 - if err != nil { 198 - return fmt.Errorf("failed to read records from repo CAR file: %w", err) 199 - } 200 - return nil 201 - } 202 - 203 - func runRepoInspect(cctx *cli.Context) error { 204 - ctx := context.Background() 205 - carPath := cctx.Args().First() 206 - if carPath == "" { 207 - return fmt.Errorf("need to provide path to CAR file as argument") 208 - } 209 - fi, err := os.Open(carPath) 210 - if err != nil { 211 - return err 212 - } 213 - 214 - // read repository tree in to memory 215 - c, _, err := repo.LoadRepoFromCAR(ctx, fi) 216 - if err != nil { 217 - return err 218 - } 219 - 220 - fmt.Printf("ATProto Repo Spec Version: %d\n", c.Version) 221 - fmt.Printf("DID: %s\n", c.DID) 222 - fmt.Printf("Data CID: %s\n", c.Data) 223 - fmt.Printf("Prev CID: %s\n", c.Prev) 224 - fmt.Printf("Revision: %s\n", c.Rev) 225 - // TODO: Signature? 226 - 227 - return nil 228 - } 229 - 230 - func runRepoMST(cctx *cli.Context) error { 231 - ctx := context.Background() 232 - opts := repoMSTOptions{ 233 - carPath: cctx.Args().First(), 234 - fullCID: cctx.Bool("full-cid"), 235 - root: cctx.String("root"), 236 - } 237 - // read from file or stdin 238 - if opts.carPath == "" { 239 - return fmt.Errorf("need to provide path to CAR file as argument") 240 - } 241 - inputCAR, err := getFileOrStdin(opts.carPath) 242 - if err != nil { 243 - return err 244 - } 245 - return prettyMST(ctx, inputCAR, opts) 246 - } 247 - 248 - func runRepoUnpack(cctx *cli.Context) error { 249 - ctx := context.Background() 250 - carPath := cctx.Args().First() 251 - if carPath == "" { 252 - return fmt.Errorf("need to provide path to CAR file as argument") 253 - } 254 - fi, err := os.Open(carPath) 255 - if err != nil { 256 - return err 257 - } 258 - 259 - c, r, err := repo.LoadRepoFromCAR(ctx, fi) 260 - if err != nil { 261 - return err 262 - } 263 - 264 - // extract DID from repo commit 265 - did, err := syntax.ParseDID(c.DID) 266 - if err != nil { 267 - return err 268 - } 269 - 270 - topDir := cctx.String("output") 271 - if topDir == "" { 272 - topDir = did.String() 273 - } 274 - fmt.Printf("writing output to: %s\n", topDir) 275 - 276 - // first the commit object as a meta file 277 - commitPath := topDir + "/_commit.json" 278 - os.MkdirAll(filepath.Dir(commitPath), os.ModePerm) 279 - commitJSON, err := json.MarshalIndent(c, "", " ") 280 - if err != nil { 281 - return err 282 - } 283 - if err := os.WriteFile(commitPath, commitJSON, 0666); err != nil { 284 - return err 285 - } 286 - 287 - // then all the actual records 288 - err = r.MST.Walk(func(k []byte, v cid.Cid) error { 289 - col, rkey, err := syntax.ParseRepoPath(string(k)) 290 - if err != nil { 291 - return err 292 - } 293 - recBytes, _, err := r.GetRecordBytes(ctx, col, rkey) 294 - if err != nil { 295 - return err 296 - } 297 - 298 - rec, err := data.UnmarshalCBOR(recBytes) 299 - if err != nil { 300 - return err 301 - } 302 - 303 - recPath := topDir + "/" + string(k) 304 - fmt.Printf("%s.json\n", recPath) 305 - err = os.MkdirAll(filepath.Dir(recPath), os.ModePerm) 306 - if err != nil { 307 - return err 308 - } 309 - recJSON, err := json.MarshalIndent(rec, "", " ") 310 - if err != nil { 311 - return err 312 - } 313 - if err := os.WriteFile(recPath+".json", recJSON, 0666); err != nil { 314 - return err 315 - } 316 - 317 - return nil 318 - }) 319 - if err != nil { 320 - return err 321 - } 322 - return nil 323 - }
-127
cmd/goat/repo_prettyprint.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "io" 8 - "strings" 9 - 10 - "github.com/bluesky-social/indigo/mst" 11 - "github.com/bluesky-social/indigo/repo" 12 - "github.com/bluesky-social/indigo/util" 13 - 14 - "github.com/ipfs/go-cid" 15 - cbor "github.com/ipfs/go-ipld-cbor" 16 - ipld "github.com/ipfs/go-ipld-format" 17 - "github.com/xlab/treeprint" 18 - ) 19 - 20 - func prettyMST(ctx context.Context, carFile io.Reader, opts repoMSTOptions) error { 21 - 22 - // read repository tree in to memory 23 - r, err := repo.ReadRepoFromCar(ctx, carFile) 24 - if err != nil { 25 - return err 26 - } 27 - cst := util.CborStore(r.Blockstore()) 28 - // determine which root cid to use, defaulting to repo data root 29 - rootCID := r.DataCid() 30 - if opts.root != "" { 31 - optsRootCID, err := cid.Decode(opts.root) 32 - if err != nil { 33 - return err 34 - } 35 - rootCID = optsRootCID 36 - } 37 - // start walking mst 38 - exists, err := nodeExists(ctx, cst, rootCID) 39 - if err != nil { 40 - return err 41 - } 42 - tree := treeprint.NewWithRoot(displayCID(&rootCID, exists, opts)) 43 - if exists { 44 - if err := walkMST(ctx, cst, rootCID, tree, opts); err != nil { 45 - return err 46 - } 47 - } 48 - // print tree 49 - fmt.Println(tree.String()) 50 - return nil 51 - } 52 - 53 - func walkMST(ctx context.Context, cst *cbor.BasicIpldStore, cid cid.Cid, tree treeprint.Tree, opts repoMSTOptions) error { 54 - var node mst.NodeData 55 - if err := cst.Get(ctx, cid, &node); err != nil { 56 - return err 57 - } 58 - if node.Left != nil { 59 - exists, err := nodeExists(ctx, cst, *node.Left) 60 - if err != nil { 61 - return err 62 - } 63 - subtree := tree.AddBranch(displayCID(node.Left, exists, opts)) 64 - if exists { 65 - if err := walkMST(ctx, cst, *node.Left, subtree, opts); err != nil { 66 - return err 67 - } 68 - } 69 - } 70 - for _, entry := range node.Entries { 71 - exists, err := nodeExists(ctx, cst, entry.Val) 72 - if err != nil { 73 - return err 74 - } 75 - tree.AddNode(displayEntryVal(&entry, exists, opts)) 76 - if entry.Tree != nil { 77 - exists, err := nodeExists(ctx, cst, *entry.Tree) 78 - if err != nil { 79 - return err 80 - } 81 - subtree := tree.AddBranch(displayCID(entry.Tree, exists, opts)) 82 - if exists { 83 - if err := walkMST(ctx, cst, *entry.Tree, subtree, opts); err != nil { 84 - return err 85 - } 86 - } 87 - } 88 - } 89 - return nil 90 - } 91 - 92 - func displayEntryVal(entry *mst.TreeEntry, exists bool, opts repoMSTOptions) string { 93 - key := string(entry.KeySuffix) 94 - divider := " " 95 - if opts.fullCID { 96 - divider = "\n" 97 - } 98 - return strings.Repeat("∙", int(entry.PrefixLen)) + key + divider + displayCID(&entry.Val, exists, opts) 99 - } 100 - 101 - func displayCID(cid *cid.Cid, exists bool, opts repoMSTOptions) string { 102 - cidDisplay := cid.String() 103 - if !opts.fullCID { 104 - cidDisplay = "…" + string(cidDisplay[len(cidDisplay)-7:]) 105 - } 106 - connector := "─◉" 107 - if !exists { 108 - connector = "─◌" 109 - } 110 - return "[" + cidDisplay + "]" + connector 111 - } 112 - 113 - type repoMSTOptions struct { 114 - carPath string 115 - fullCID bool 116 - root string 117 - } 118 - 119 - func nodeExists(ctx context.Context, cst *cbor.BasicIpldStore, cid cid.Cid) (bool, error) { 120 - if _, err := cst.Blocks.Get(ctx, cid); err != nil { 121 - if errors.Is(err, ipld.ErrNotFound{}) { 122 - return false, nil 123 - } 124 - return false, err 125 - } 126 - return true, nil 127 - }
-285
cmd/goat/syntax.go
··· 1 - package main 2 - 3 - import ( 4 - "fmt" 5 - "time" 6 - 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - 9 - "github.com/urfave/cli/v2" 10 - ) 11 - 12 - var cmdSyntax = &cli.Command{ 13 - Name: "syntax", 14 - Usage: "sub-commands for string syntax helpers", 15 - Subcommands: []*cli.Command{ 16 - &cli.Command{ 17 - Name: "tid", 18 - Usage: "sub-commands for TIDs", 19 - Subcommands: []*cli.Command{ 20 - &cli.Command{ 21 - Name: "check", 22 - Usage: "validates TID syntax", 23 - ArgsUsage: `<tid>`, 24 - Action: runSyntaxTIDCheck, 25 - }, 26 - &cli.Command{ 27 - Name: "inspect", 28 - Usage: "parses a TID to timestamp", 29 - ArgsUsage: `<tid>`, 30 - Action: runSyntaxTIDInspect, 31 - }, 32 - &cli.Command{ 33 - Name: "generate", 34 - Usage: "outputs a new TID", 35 - Aliases: []string{"now"}, 36 - Action: runSyntaxTIDGenerate, 37 - }, 38 - }, 39 - }, 40 - &cli.Command{ 41 - Name: "handle", 42 - Usage: "sub-commands for handle syntax", 43 - Subcommands: []*cli.Command{ 44 - &cli.Command{ 45 - Name: "check", 46 - Usage: "validates handle syntax", 47 - ArgsUsage: `<handle>`, 48 - Action: runSyntaxHandleCheck, 49 - }, 50 - }, 51 - }, 52 - &cli.Command{ 53 - Name: "did", 54 - Usage: "sub-commands for DID syntax", 55 - Subcommands: []*cli.Command{ 56 - &cli.Command{ 57 - Name: "check", 58 - Usage: "validates DID syntax", 59 - ArgsUsage: `<did>`, 60 - Action: runSyntaxDIDCheck, 61 - }, 62 - }, 63 - }, 64 - &cli.Command{ 65 - Name: "cid", 66 - Usage: "sub-commands for CID syntax", 67 - Subcommands: []*cli.Command{ 68 - &cli.Command{ 69 - Name: "check", 70 - Usage: "validates CID syntax", 71 - ArgsUsage: `<cid>`, 72 - Action: runSyntaxCIDCheck, 73 - }, 74 - }, 75 - }, 76 - &cli.Command{ 77 - Name: "rkey", 78 - Usage: "sub-commands for record key syntax", 79 - Subcommands: []*cli.Command{ 80 - &cli.Command{ 81 - Name: "check", 82 - Usage: "validates record key syntax", 83 - ArgsUsage: `<rkey>`, 84 - Action: runSyntaxRecordKeyCheck, 85 - }, 86 - }, 87 - }, 88 - &cli.Command{ 89 - Name: "nsid", 90 - Usage: "sub-commands for NSID syntax", 91 - Subcommands: []*cli.Command{ 92 - &cli.Command{ 93 - Name: "check", 94 - Usage: "validates NSID syntax", 95 - ArgsUsage: `<nsid>`, 96 - Action: runSyntaxNSIDCheck, 97 - }, 98 - }, 99 - }, 100 - &cli.Command{ 101 - Name: "at-uri", 102 - Usage: "sub-commands for AT-URI syntax", 103 - Subcommands: []*cli.Command{ 104 - &cli.Command{ 105 - Name: "check", 106 - Usage: "validates AT-URI syntax", 107 - ArgsUsage: `<uri>`, 108 - Action: runSyntaxATURICheck, 109 - }, 110 - }, 111 - }, 112 - &cli.Command{ 113 - Name: "datetime", 114 - Usage: "sub-commands for datetimes", 115 - Subcommands: []*cli.Command{ 116 - &cli.Command{ 117 - Name: "check", 118 - Usage: "validates datetime syntax", 119 - ArgsUsage: `<datetime>`, 120 - Action: runSyntaxDatetimeCheck, 121 - }, 122 - &cli.Command{ 123 - Name: "now", 124 - Usage: "outputs the current datetime", 125 - Action: runSyntaxDatetimeNow, 126 - }, 127 - }, 128 - }, 129 - &cli.Command{ 130 - Name: "language", 131 - Usage: "sub-commands for language code syntax", 132 - Subcommands: []*cli.Command{ 133 - &cli.Command{ 134 - Name: "check", 135 - Usage: "validates language code syntax", 136 - ArgsUsage: `<lang-code>`, 137 - Action: runSyntaxLanguageCheck, 138 - }, 139 - }, 140 - }, 141 - }, 142 - } 143 - 144 - func runSyntaxTIDCheck(cctx *cli.Context) error { 145 - s := cctx.Args().First() 146 - if s == "" { 147 - return fmt.Errorf("need to provide identifier as argument") 148 - } 149 - _, err := syntax.ParseTID(s) 150 - if err != nil { 151 - return err 152 - } 153 - fmt.Println("valid") 154 - return nil 155 - } 156 - 157 - func runSyntaxTIDGenerate(cctx *cli.Context) error { 158 - fmt.Printf("%s\n", syntax.NewTIDNow(0).String()) 159 - return nil 160 - } 161 - 162 - func runSyntaxTIDInspect(cctx *cli.Context) error { 163 - s := cctx.Args().First() 164 - if s == "" { 165 - return fmt.Errorf("need to provide identifier as argument") 166 - } 167 - tid, err := syntax.ParseTID(s) 168 - if err != nil { 169 - return err 170 - } 171 - fmt.Printf("Timestamp (UTC): %s\n", tid.Time().Format(syntax.AtprotoDatetimeLayout)) 172 - fmt.Printf("Timestamp (Local): %s\n", tid.Time().Local().Format(time.RFC3339)) 173 - fmt.Printf("ClockID: %d\n", tid.ClockID()) 174 - fmt.Printf("uint64: 0x%x\n", tid.Integer()) 175 - return nil 176 - } 177 - 178 - func runSyntaxRecordKeyCheck(cctx *cli.Context) error { 179 - s := cctx.Args().First() 180 - if s == "" { 181 - return fmt.Errorf("need to provide identifier as argument") 182 - } 183 - _, err := syntax.ParseRecordKey(s) 184 - if err != nil { 185 - return err 186 - } 187 - fmt.Println("valid") 188 - return nil 189 - } 190 - 191 - func runSyntaxDIDCheck(cctx *cli.Context) error { 192 - s := cctx.Args().First() 193 - if s == "" { 194 - return fmt.Errorf("need to provide identifier as argument") 195 - } 196 - _, err := syntax.ParseDID(s) 197 - if err != nil { 198 - return err 199 - } 200 - fmt.Println("valid") 201 - return nil 202 - } 203 - 204 - func runSyntaxCIDCheck(cctx *cli.Context) error { 205 - s := cctx.Args().First() 206 - if s == "" { 207 - return fmt.Errorf("need to provide identifier as argument") 208 - } 209 - _, err := syntax.ParseCID(s) 210 - if err != nil { 211 - return err 212 - } 213 - fmt.Println("valid") 214 - return nil 215 - } 216 - 217 - func runSyntaxHandleCheck(cctx *cli.Context) error { 218 - s := cctx.Args().First() 219 - if s == "" { 220 - return fmt.Errorf("need to provide identifier as argument") 221 - } 222 - _, err := syntax.ParseHandle(s) 223 - if err != nil { 224 - return err 225 - } 226 - fmt.Println("valid") 227 - return nil 228 - } 229 - 230 - func runSyntaxNSIDCheck(cctx *cli.Context) error { 231 - s := cctx.Args().First() 232 - if s == "" { 233 - return fmt.Errorf("need to provide identifier as argument") 234 - } 235 - _, err := syntax.ParseNSID(s) 236 - if err != nil { 237 - return err 238 - } 239 - fmt.Println("valid") 240 - return nil 241 - } 242 - 243 - func runSyntaxATURICheck(cctx *cli.Context) error { 244 - s := cctx.Args().First() 245 - if s == "" { 246 - return fmt.Errorf("need to provide identifier as argument") 247 - } 248 - _, err := syntax.ParseATURI(s) 249 - if err != nil { 250 - return err 251 - } 252 - fmt.Println("valid") 253 - return nil 254 - } 255 - 256 - func runSyntaxDatetimeCheck(cctx *cli.Context) error { 257 - s := cctx.Args().First() 258 - if s == "" { 259 - return fmt.Errorf("need to provide identifier as argument") 260 - } 261 - _, err := syntax.ParseDatetime(s) 262 - if err != nil { 263 - return err 264 - } 265 - fmt.Println("valid") 266 - return nil 267 - } 268 - 269 - func runSyntaxDatetimeNow(cctx *cli.Context) error { 270 - fmt.Printf("%s\n", syntax.DatetimeNow().String()) 271 - return nil 272 - } 273 - 274 - func runSyntaxLanguageCheck(cctx *cli.Context) error { 275 - s := cctx.Args().First() 276 - if s == "" { 277 - return fmt.Errorf("need to provide identifier as argument") 278 - } 279 - _, err := syntax.ParseLanguage(s) 280 - if err != nil { 281 - return err 282 - } 283 - fmt.Println("valid") 284 - return nil 285 - }
-77
cmd/goat/util.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "io" 7 - "log/slog" 8 - "os" 9 - "strings" 10 - 11 - "github.com/bluesky-social/indigo/atproto/identity" 12 - "github.com/bluesky-social/indigo/atproto/syntax" 13 - 14 - "github.com/carlmjohnson/versioninfo" 15 - "github.com/urfave/cli/v2" 16 - ) 17 - 18 - func resolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { 19 - id, err := syntax.ParseAtIdentifier(arg) 20 - if err != nil { 21 - return nil, err 22 - } 23 - 24 - dir := identity.DefaultDirectory() 25 - return dir.Lookup(ctx, *id) 26 - } 27 - 28 - const stdIOPath = "-" 29 - 30 - func getFileOrStdin(path string) (io.Reader, error) { 31 - if path == stdIOPath { 32 - return os.Stdin, nil 33 - } 34 - file, err := os.Open(path) 35 - if err != nil { 36 - return nil, err 37 - } 38 - return file, nil 39 - } 40 - 41 - func getFileOrStdout(path string) (io.WriteCloser, error) { 42 - if path == stdIOPath { 43 - return os.Stdout, nil 44 - } 45 - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666) 46 - if err != nil { 47 - return nil, err 48 - } 49 - return file, nil 50 - } 51 - 52 - func configLogger(cctx *cli.Context, writer io.Writer) *slog.Logger { 53 - var level slog.Level 54 - switch strings.ToLower(cctx.String("log-level")) { 55 - case "error": 56 - level = slog.LevelError 57 - case "warn": 58 - level = slog.LevelWarn 59 - case "info": 60 - level = slog.LevelInfo 61 - case "debug": 62 - level = slog.LevelDebug 63 - default: 64 - level = slog.LevelInfo 65 - } 66 - logger := slog.New(slog.NewJSONHandler(writer, &slog.HandlerOptions{ 67 - Level: level, 68 - })) 69 - slog.SetDefault(logger) 70 - return logger 71 - } 72 - 73 - // returns a pointer because that is what xrpc.Client expects 74 - func userAgent() *string { 75 - s := fmt.Sprintf("goat/%s", versioninfo.Short()) 76 - return &s 77 - }
+2 -2
cmd/netsync/main.go
··· 19 19 "syscall" 20 20 "time" 21 21 22 - "github.com/bluesky-social/indigo/atproto/data" 22 + "github.com/bluesky-social/indigo/atproto/atdata" 23 23 "github.com/bluesky-social/indigo/repo" 24 24 "github.com/ipfs/go-cid" 25 25 _ "github.com/joho/godotenv/autoload" ··· 573 573 collectionsSeen[collection] = struct{}{} 574 574 } 575 575 576 - asCbor, err := data.UnmarshalCBOR(*rec) 576 + asCbor, err := atdata.UnmarshalCBOR(*rec) 577 577 if err != nil { 578 578 log.Error("Error unmarshalling record", "err", err) 579 579 return fmt.Errorf("Failed to unmarshal record: %w", err)
+1 -1
cmd/relay/README.md
··· 66 66 67 67 http post :2470/admin/pds/requestCrawl -a admin:dummy hostname=pds.example.com 68 68 69 - The `goat` command line tool (also part of the indigo git repository) includes helpers for administering, inspecting, and debugging relays: 69 + The `goat` command line tool includes helpers for administering, inspecting, and debugging relays: 70 70 71 71 RELAY_HOST=http://localhost:2470 goat firehose --verify-mst 72 72 RELAY_HOST=http://localhost:2470 goat relay admin host list
+4 -4
gen/main.go
··· 6 6 atproto "github.com/bluesky-social/indigo/api/atproto" 7 7 bsky "github.com/bluesky-social/indigo/api/bsky" 8 8 chat "github.com/bluesky-social/indigo/api/chat" 9 - "github.com/bluesky-social/indigo/atproto/data" 10 - "github.com/bluesky-social/indigo/atproto/label" 9 + "github.com/bluesky-social/indigo/atproto/atdata" 10 + "github.com/bluesky-social/indigo/atproto/labeling" 11 11 atrepo "github.com/bluesky-social/indigo/atproto/repo" 12 12 atmst "github.com/bluesky-social/indigo/atproto/repo/mst" 13 13 "github.com/bluesky-social/indigo/events" ··· 122 122 panic(err) 123 123 } 124 124 125 - if err := genCfg.WriteMapEncodersToFile("atproto/data/cbor_gen.go", "data", data.GenericRecord{}, data.LegacyBlobSchema{}, data.BlobSchema{}); err != nil { 125 + if err := genCfg.WriteMapEncodersToFile("atproto/atdata/cbor_gen.go", "atdata", atdata.GenericRecord{}, atdata.LegacyBlobSchema{}, atdata.BlobSchema{}); err != nil { 126 126 panic(err) 127 127 } 128 128 ··· 134 134 panic(err) 135 135 } 136 136 137 - if err := genCfg.WriteMapEncodersToFile("atproto/label/cbor_gen.go", "label", label.Label{}); err != nil { 137 + if err := genCfg.WriteMapEncodersToFile("atproto/labeling/cbor_gen.go", "labeling", labeling.Label{}); err != nil { 138 138 panic(err) 139 139 } 140 140 }
-3
go.mod
··· 7 7 require ( 8 8 github.com/PuerkitoBio/purell v1.2.1 9 9 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b 10 - github.com/adrg/xdg v0.5.0 11 10 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 12 11 github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 13 12 github.com/brianvoe/gofakeit/v6 v6.25.0 ··· 59 58 github.com/urfave/cli/v2 v2.25.7 60 59 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 61 60 github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 62 - github.com/xlab/treeprint v1.2.0 63 61 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b 64 62 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 65 63 go.opentelemetry.io/otel v1.21.0 ··· 90 88 github.com/cockroachdb/redact v1.1.5 // indirect 91 89 github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect 92 90 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 93 - github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 // indirect 94 91 github.com/getsentry/sentry-go v0.27.0 // indirect 95 92 github.com/go-redis/redis v6.15.9+incompatible // indirect 96 93 github.com/goccy/go-json v0.10.2 // indirect
-8
go.sum
··· 5 5 github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= 6 6 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 7 7 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 8 - github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY= 9 - github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4= 10 8 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM= 11 9 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA= 12 10 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= ··· 81 79 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 82 80 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 83 81 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 84 - github.com/did-method-plc/go-didplc v0.0.0-20250716162123-d0c3eba68797 h1:yYj4PNkUnWSh0Fhsl/pUoxMvBVaVeY6ZebkWMyGzW9k= 85 - github.com/did-method-plc/go-didplc v0.0.0-20250716162123-d0c3eba68797/go.mod h1:ddIXqTTSXWtj5kMsHAPj8SvbIx2GZdAkBFgFa6e6+CM= 86 - github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 h1:AGh+Vn9fXhf9eo8erG1CK4+LACduPo64P1OICQLDv88= 87 - github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038/go.mod h1:ddIXqTTSXWtj5kMsHAPj8SvbIx2GZdAkBFgFa6e6+CM= 88 82 github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2 h1:S6Dco8FtAhEI/qkg/00H6RdEGC+MCy5GPiQ+xweNRFE= 89 83 github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= 90 84 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= ··· 506 500 github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f/go.mod h1:p9UJB6dDgdPgMJZs7UjUOdulKyRr9fqkS+6JKAInPy8= 507 501 github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic= 508 502 github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s= 509 - github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= 510 - github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= 511 503 github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 512 504 github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 513 505 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=