porting all github actions from bluesky-social/indigo to tangled CI
at main 13 kB view raw
1package main 2 3import ( 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 19var 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 199func 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 211func runAccountLogout(cctx *cli.Context) error { 212 return wipeAuthSession() 213} 214 215func 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 251func 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 277func 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 305func 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 323func 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 341func 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 370func 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 408func 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 455func 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}