this repo has no description
0
fork

Configure Feed

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

remove goat code (#1168)

Code for the `goat` CLI was moved a while back. This removes the (now)
stale code from this repo.

authored by

bnewbold and committed by
GitHub
3bc289db 3259b215

+3 -5885
-1
.gitignore
··· 26 26 /bigsky 27 27 /bluepages 28 28 /fakermaker 29 - /goat 30 29 /gosky 31 30 /hepa 32 31 /lexgen
-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
+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)
+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 - }
+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