forked from tangled.org/core
this repo has no description
1package state 2 3import ( 4 "context" 5 "crypto/hmac" 6 "crypto/sha256" 7 "encoding/hex" 8 "fmt" 9 "log" 10 "log/slog" 11 "net/http" 12 "strings" 13 "time" 14 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 securejoin "github.com/cyphar/filepath-securejoin" 19 "github.com/go-chi/chi/v5" 20 tangled "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview" 22 "tangled.sh/tangled.sh/core/appview/auth" 23 "tangled.sh/tangled.sh/core/appview/db" 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/jetstream" 26 "tangled.sh/tangled.sh/core/rbac" 27) 28 29type State struct { 30 db *db.DB 31 auth *auth.Auth 32 enforcer *rbac.Enforcer 33 tidClock *syntax.TIDClock 34 pages *pages.Pages 35 resolver *appview.Resolver 36 jc *jetstream.JetstreamClient 37 config *appview.Config 38} 39 40func Make(config *appview.Config) (*State, error) { 41 d, err := db.Make(config.DbPath) 42 if err != nil { 43 return nil, err 44 } 45 46 auth, err := auth.Make(config.CookieSecret) 47 if err != nil { 48 return nil, err 49 } 50 51 enforcer, err := rbac.NewEnforcer(config.DbPath) 52 if err != nil { 53 return nil, err 54 } 55 56 clock := syntax.NewTIDClock(0) 57 58 pgs := pages.NewPages() 59 60 resolver := appview.NewResolver() 61 62 wrapper := db.DbWrapper{d} 63 jc, err := jetstream.NewJetstreamClient(config.JetstreamEndpoint, "appview", []string{tangled.GraphFollowNSID}, nil, slog.Default(), wrapper, false) 64 if err != nil { 65 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 66 } 67 err = jc.StartJetstream(context.Background(), jetstreamIngester(wrapper)) 68 if err != nil { 69 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 70 } 71 72 state := &State{ 73 d, 74 auth, 75 enforcer, 76 clock, 77 pgs, 78 resolver, 79 jc, 80 config, 81 } 82 83 return state, nil 84} 85 86func (s *State) TID() string { 87 return s.tidClock.Next().String() 88} 89 90func (s *State) Login(w http.ResponseWriter, r *http.Request) { 91 ctx := r.Context() 92 93 switch r.Method { 94 case http.MethodGet: 95 err := s.pages.Login(w, pages.LoginParams{}) 96 if err != nil { 97 log.Printf("rendering login page: %s", err) 98 } 99 100 return 101 case http.MethodPost: 102 handle := strings.TrimPrefix(r.FormValue("handle"), "@") 103 appPassword := r.FormValue("app_password") 104 105 resolved, err := s.resolver.ResolveIdent(ctx, handle) 106 if err != nil { 107 log.Println("failed to resolve handle:", err) 108 s.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 109 return 110 } 111 112 atSession, err := s.auth.CreateInitialSession(ctx, resolved, appPassword) 113 if err != nil { 114 s.pages.Notice(w, "login-msg", "Invalid handle or password.") 115 return 116 } 117 sessionish := auth.CreateSessionWrapper{ServerCreateSession_Output: atSession} 118 119 err = s.auth.StoreSession(r, w, &sessionish, resolved.PDSEndpoint()) 120 if err != nil { 121 s.pages.Notice(w, "login-msg", "Failed to login, try again later.") 122 return 123 } 124 125 log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did) 126 127 did := resolved.DID.String() 128 defaultKnot := "knot1.tangled.sh" 129 130 go func() { 131 log.Printf("adding %s to default knot", did) 132 err = s.enforcer.AddMember(defaultKnot, did) 133 if err != nil { 134 log.Println("failed to add user to knot1.tangled.sh: ", err) 135 return 136 } 137 err = s.enforcer.E.SavePolicy() 138 if err != nil { 139 log.Println("failed to add user to knot1.tangled.sh: ", err) 140 return 141 } 142 143 secret, err := db.GetRegistrationKey(s.db, defaultKnot) 144 if err != nil { 145 log.Println("failed to get registration key for knot1.tangled.sh") 146 return 147 } 148 signedClient, err := NewSignedClient(defaultKnot, secret, s.config.Dev) 149 resp, err := signedClient.AddMember(did) 150 if err != nil { 151 log.Println("failed to add user to knot1.tangled.sh: ", err) 152 return 153 } 154 155 if resp.StatusCode != http.StatusNoContent { 156 log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 157 return 158 } 159 }() 160 161 s.pages.HxRedirect(w, "/") 162 return 163 } 164} 165 166func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 167 s.auth.ClearSession(r, w) 168 http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 169} 170 171func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 172 user := s.auth.GetUser(r) 173 174 timeline, err := db.MakeTimeline(s.db) 175 if err != nil { 176 log.Println(err) 177 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 178 } 179 180 var didsToResolve []string 181 for _, ev := range timeline { 182 if ev.Repo != nil { 183 didsToResolve = append(didsToResolve, ev.Repo.Did) 184 } 185 if ev.Follow != nil { 186 didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) 187 } 188 if ev.Star != nil { 189 didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did) 190 } 191 } 192 193 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 194 didHandleMap := make(map[string]string) 195 for _, identity := range resolvedIds { 196 if !identity.Handle.IsInvalidHandle() { 197 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 198 } else { 199 didHandleMap[identity.DID.String()] = identity.DID.String() 200 } 201 } 202 203 s.pages.Timeline(w, pages.TimelineParams{ 204 LoggedInUser: user, 205 Timeline: timeline, 206 DidHandleMap: didHandleMap, 207 }) 208 209 return 210} 211 212// requires auth 213func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) { 214 switch r.Method { 215 case http.MethodGet: 216 // list open registrations under this did 217 218 return 219 case http.MethodPost: 220 session, err := s.auth.Store.Get(r, appview.SessionName) 221 if err != nil || session.IsNew { 222 log.Println("unauthorized attempt to generate registration key") 223 http.Error(w, "Forbidden", http.StatusUnauthorized) 224 return 225 } 226 227 did := session.Values[appview.SessionDid].(string) 228 229 // check if domain is valid url, and strip extra bits down to just host 230 domain := r.FormValue("domain") 231 if domain == "" { 232 http.Error(w, "Invalid form", http.StatusBadRequest) 233 return 234 } 235 236 key, err := db.GenerateRegistrationKey(s.db, domain, did) 237 238 if err != nil { 239 log.Println(err) 240 http.Error(w, "unable to register this domain", http.StatusNotAcceptable) 241 return 242 } 243 244 w.Write([]byte(key)) 245 } 246} 247 248func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 249 user := chi.URLParam(r, "user") 250 user = strings.TrimPrefix(user, "@") 251 252 if user == "" { 253 w.WriteHeader(http.StatusBadRequest) 254 return 255 } 256 257 id, err := s.resolver.ResolveIdent(r.Context(), user) 258 if err != nil { 259 w.WriteHeader(http.StatusInternalServerError) 260 return 261 } 262 263 pubKeys, err := db.GetPublicKeys(s.db, id.DID.String()) 264 if err != nil { 265 w.WriteHeader(http.StatusNotFound) 266 return 267 } 268 269 if len(pubKeys) == 0 { 270 w.WriteHeader(http.StatusNotFound) 271 return 272 } 273 274 for _, k := range pubKeys { 275 key := strings.TrimRight(k.Key, "\n") 276 w.Write([]byte(fmt.Sprintln(key))) 277 } 278} 279 280// create a signed request and check if a node responds to that 281func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { 282 user := s.auth.GetUser(r) 283 284 domain := chi.URLParam(r, "domain") 285 if domain == "" { 286 http.Error(w, "malformed url", http.StatusBadRequest) 287 return 288 } 289 log.Println("checking ", domain) 290 291 secret, err := db.GetRegistrationKey(s.db, domain) 292 if err != nil { 293 log.Printf("no key found for domain %s: %s\n", domain, err) 294 return 295 } 296 297 client, err := NewSignedClient(domain, secret, s.config.Dev) 298 if err != nil { 299 log.Println("failed to create client to ", domain) 300 } 301 302 resp, err := client.Init(user.Did) 303 if err != nil { 304 w.Write([]byte("no dice")) 305 log.Println("domain was unreachable after 5 seconds") 306 return 307 } 308 309 if resp.StatusCode == http.StatusConflict { 310 log.Println("status conflict", resp.StatusCode) 311 w.Write([]byte("already registered, sorry!")) 312 return 313 } 314 315 if resp.StatusCode != http.StatusNoContent { 316 log.Println("status nok", resp.StatusCode) 317 w.Write([]byte("no dice")) 318 return 319 } 320 321 // verify response mac 322 signature := resp.Header.Get("X-Signature") 323 signatureBytes, err := hex.DecodeString(signature) 324 if err != nil { 325 return 326 } 327 328 expectedMac := hmac.New(sha256.New, []byte(secret)) 329 expectedMac.Write([]byte("ok")) 330 331 if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 332 log.Printf("response body signature mismatch: %x\n", signatureBytes) 333 return 334 } 335 336 tx, err := s.db.BeginTx(r.Context(), nil) 337 if err != nil { 338 log.Println("failed to start tx", err) 339 http.Error(w, err.Error(), http.StatusInternalServerError) 340 return 341 } 342 defer func() { 343 tx.Rollback() 344 err = s.enforcer.E.LoadPolicy() 345 if err != nil { 346 log.Println("failed to rollback policies") 347 } 348 }() 349 350 // mark as registered 351 err = db.Register(tx, domain) 352 if err != nil { 353 log.Println("failed to register domain", err) 354 http.Error(w, err.Error(), http.StatusInternalServerError) 355 return 356 } 357 358 // set permissions for this did as owner 359 reg, err := db.RegistrationByDomain(tx, domain) 360 if err != nil { 361 log.Println("failed to register domain", err) 362 http.Error(w, err.Error(), http.StatusInternalServerError) 363 return 364 } 365 366 // add basic acls for this domain 367 err = s.enforcer.AddDomain(domain) 368 if err != nil { 369 log.Println("failed to setup owner of domain", err) 370 http.Error(w, err.Error(), http.StatusInternalServerError) 371 return 372 } 373 374 // add this did as owner of this domain 375 err = s.enforcer.AddOwner(domain, reg.ByDid) 376 if err != nil { 377 log.Println("failed to setup owner of domain", err) 378 http.Error(w, err.Error(), http.StatusInternalServerError) 379 return 380 } 381 382 err = tx.Commit() 383 if err != nil { 384 log.Println("failed to commit changes", err) 385 http.Error(w, err.Error(), http.StatusInternalServerError) 386 return 387 } 388 389 err = s.enforcer.E.SavePolicy() 390 if err != nil { 391 log.Println("failed to update ACLs", err) 392 http.Error(w, err.Error(), http.StatusInternalServerError) 393 return 394 } 395 396 w.Write([]byte("check success")) 397} 398 399func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) { 400 domain := chi.URLParam(r, "domain") 401 if domain == "" { 402 http.Error(w, "malformed url", http.StatusBadRequest) 403 return 404 } 405 406 user := s.auth.GetUser(r) 407 reg, err := db.RegistrationByDomain(s.db, domain) 408 if err != nil { 409 w.Write([]byte("failed to pull up registration info")) 410 return 411 } 412 413 var members []string 414 if reg.Registered != nil { 415 members, err = s.enforcer.GetUserByRole("server:member", domain) 416 if err != nil { 417 w.Write([]byte("failed to fetch member list")) 418 return 419 } 420 } 421 422 ok, err := s.enforcer.IsServerOwner(user.Did, domain) 423 isOwner := err == nil && ok 424 425 p := pages.KnotParams{ 426 LoggedInUser: user, 427 Registration: reg, 428 Members: members, 429 IsOwner: isOwner, 430 } 431 432 s.pages.Knot(w, p) 433} 434 435// get knots registered by this user 436func (s *State) Knots(w http.ResponseWriter, r *http.Request) { 437 // for now, this is just pubkeys 438 user := s.auth.GetUser(r) 439 registrations, err := db.RegistrationsByDid(s.db, user.Did) 440 if err != nil { 441 log.Println(err) 442 } 443 444 s.pages.Knots(w, pages.KnotsParams{ 445 LoggedInUser: user, 446 Registrations: registrations, 447 }) 448} 449 450// list members of domain, requires auth and requires owner status 451func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) { 452 domain := chi.URLParam(r, "domain") 453 if domain == "" { 454 http.Error(w, "malformed url", http.StatusBadRequest) 455 return 456 } 457 458 // list all members for this domain 459 memberDids, err := s.enforcer.GetUserByRole("server:member", domain) 460 if err != nil { 461 w.Write([]byte("failed to fetch member list")) 462 return 463 } 464 465 w.Write([]byte(strings.Join(memberDids, "\n"))) 466 return 467} 468 469// add member to domain, requires auth and requires invite access 470func (s *State) AddMember(w http.ResponseWriter, r *http.Request) { 471 domain := chi.URLParam(r, "domain") 472 if domain == "" { 473 http.Error(w, "malformed url", http.StatusBadRequest) 474 return 475 } 476 477 memberDid := r.FormValue("member") 478 if memberDid == "" { 479 http.Error(w, "malformed form", http.StatusBadRequest) 480 return 481 } 482 483 memberIdent, err := s.resolver.ResolveIdent(r.Context(), memberDid) 484 if err != nil { 485 w.Write([]byte("failed to resolve member did to a handle")) 486 return 487 } 488 log.Printf("adding %s to %s\n", memberIdent.Handle.String(), domain) 489 490 // announce this relation into the firehose, store into owners' pds 491 client, _ := s.auth.AuthorizedClient(r) 492 currentUser := s.auth.GetUser(r) 493 addedAt := time.Now().Format(time.RFC3339) 494 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 495 Collection: tangled.KnotMemberNSID, 496 Repo: currentUser.Did, 497 Rkey: s.TID(), 498 Record: &lexutil.LexiconTypeDecoder{ 499 Val: &tangled.KnotMember{ 500 Member: memberIdent.DID.String(), 501 Domain: domain, 502 AddedAt: &addedAt, 503 }}, 504 }) 505 506 // invalid record 507 if err != nil { 508 log.Printf("failed to create record: %s", err) 509 return 510 } 511 log.Println("created atproto record: ", resp.Uri) 512 513 secret, err := db.GetRegistrationKey(s.db, domain) 514 if err != nil { 515 log.Printf("no key found for domain %s: %s\n", domain, err) 516 return 517 } 518 519 ksClient, err := NewSignedClient(domain, secret, s.config.Dev) 520 if err != nil { 521 log.Println("failed to create client to ", domain) 522 return 523 } 524 525 ksResp, err := ksClient.AddMember(memberIdent.DID.String()) 526 if err != nil { 527 log.Printf("failed to make request to %s: %s", domain, err) 528 return 529 } 530 531 if ksResp.StatusCode != http.StatusNoContent { 532 w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err))) 533 return 534 } 535 536 err = s.enforcer.AddMember(domain, memberIdent.DID.String()) 537 if err != nil { 538 w.Write([]byte(fmt.Sprint("failed to add member: ", err))) 539 return 540 } 541 542 w.Write([]byte(fmt.Sprint("added member: ", memberIdent.Handle.String()))) 543} 544 545func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { 546} 547 548func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 549 switch r.Method { 550 case http.MethodGet: 551 user := s.auth.GetUser(r) 552 knots, err := s.enforcer.GetDomainsForUser(user.Did) 553 if err != nil { 554 s.pages.Notice(w, "repo", "Invalid user account.") 555 return 556 } 557 558 s.pages.NewRepo(w, pages.NewRepoParams{ 559 LoggedInUser: user, 560 Knots: knots, 561 }) 562 563 case http.MethodPost: 564 user := s.auth.GetUser(r) 565 566 domain := r.FormValue("domain") 567 if domain == "" { 568 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 569 return 570 } 571 572 repoName := r.FormValue("name") 573 if repoName == "" { 574 s.pages.Notice(w, "repo", "Repository name cannot be empty.") 575 return 576 } 577 578 // Check for valid repository name (GitHub-like rules) 579 // No spaces, only alphanumeric characters, dashes, and underscores 580 for _, char := range repoName { 581 if !((char >= 'a' && char <= 'z') || 582 (char >= 'A' && char <= 'Z') || 583 (char >= '0' && char <= '9') || 584 char == '-' || char == '_' || char == '.') { 585 s.pages.Notice(w, "repo", "Repository name can only contain alphanumeric characters, periods, hyphens, and underscores.") 586 return 587 } 588 } 589 590 defaultBranch := r.FormValue("branch") 591 if defaultBranch == "" { 592 defaultBranch = "main" 593 } 594 595 description := r.FormValue("description") 596 597 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 598 if err != nil || !ok { 599 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 600 return 601 } 602 603 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 604 if err == nil && existingRepo != nil { 605 s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 606 return 607 } 608 609 secret, err := db.GetRegistrationKey(s.db, domain) 610 if err != nil { 611 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 612 return 613 } 614 615 client, err := NewSignedClient(domain, secret, s.config.Dev) 616 if err != nil { 617 s.pages.Notice(w, "repo", "Failed to connect to knot server.") 618 return 619 } 620 621 rkey := s.TID() 622 repo := &db.Repo{ 623 Did: user.Did, 624 Name: repoName, 625 Knot: domain, 626 Rkey: rkey, 627 Description: description, 628 } 629 630 xrpcClient, _ := s.auth.AuthorizedClient(r) 631 632 addedAt := time.Now().Format(time.RFC3339) 633 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ 634 Collection: tangled.RepoNSID, 635 Repo: user.Did, 636 Rkey: rkey, 637 Record: &lexutil.LexiconTypeDecoder{ 638 Val: &tangled.Repo{ 639 Knot: repo.Knot, 640 Name: repoName, 641 AddedAt: &addedAt, 642 Owner: user.Did, 643 }}, 644 }) 645 if err != nil { 646 log.Printf("failed to create record: %s", err) 647 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 648 return 649 } 650 log.Println("created repo record: ", atresp.Uri) 651 652 tx, err := s.db.BeginTx(r.Context(), nil) 653 if err != nil { 654 log.Println(err) 655 s.pages.Notice(w, "repo", "Failed to save repository information.") 656 return 657 } 658 defer func() { 659 tx.Rollback() 660 err = s.enforcer.E.LoadPolicy() 661 if err != nil { 662 log.Println("failed to rollback policies") 663 } 664 }() 665 666 resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 667 if err != nil { 668 s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 669 return 670 } 671 672 switch resp.StatusCode { 673 case http.StatusConflict: 674 s.pages.Notice(w, "repo", "A repository with that name already exists.") 675 return 676 case http.StatusInternalServerError: 677 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 678 case http.StatusNoContent: 679 // continue 680 } 681 682 repo.AtUri = atresp.Uri 683 err = db.AddRepo(tx, repo) 684 if err != nil { 685 log.Println(err) 686 s.pages.Notice(w, "repo", "Failed to save repository information.") 687 return 688 } 689 690 // acls 691 p, _ := securejoin.SecureJoin(user.Did, repoName) 692 err = s.enforcer.AddRepo(user.Did, domain, p) 693 if err != nil { 694 log.Println(err) 695 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 696 return 697 } 698 699 err = tx.Commit() 700 if err != nil { 701 log.Println("failed to commit changes", err) 702 http.Error(w, err.Error(), http.StatusInternalServerError) 703 return 704 } 705 706 err = s.enforcer.E.SavePolicy() 707 if err != nil { 708 log.Println("failed to update ACLs", err) 709 http.Error(w, err.Error(), http.StatusInternalServerError) 710 return 711 } 712 713 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 714 return 715 } 716} 717 718func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { 719 didOrHandle := chi.URLParam(r, "user") 720 if didOrHandle == "" { 721 http.Error(w, "Bad request", http.StatusBadRequest) 722 return 723 } 724 725 ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle) 726 if err != nil { 727 log.Printf("resolving identity: %s", err) 728 w.WriteHeader(http.StatusNotFound) 729 return 730 } 731 732 repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 733 if err != nil { 734 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 735 } 736 737 collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 738 if err != nil { 739 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 740 } 741 var didsToResolve []string 742 for _, r := range collaboratingRepos { 743 didsToResolve = append(didsToResolve, r.Did) 744 } 745 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 746 didHandleMap := make(map[string]string) 747 for _, identity := range resolvedIds { 748 if !identity.Handle.IsInvalidHandle() { 749 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 750 } else { 751 didHandleMap[identity.DID.String()] = identity.DID.String() 752 } 753 } 754 755 followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 756 if err != nil { 757 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 758 } 759 760 loggedInUser := s.auth.GetUser(r) 761 followStatus := db.IsNotFollowing 762 if loggedInUser != nil { 763 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 764 } 765 766 profileAvatarUri, err := GetAvatarUri(ident.Handle.String()) 767 if err != nil { 768 log.Println("failed to fetch bsky avatar", err) 769 } 770 771 s.pages.ProfilePage(w, pages.ProfilePageParams{ 772 LoggedInUser: loggedInUser, 773 UserDid: ident.DID.String(), 774 UserHandle: ident.Handle.String(), 775 Repos: repos, 776 CollaboratingRepos: collaboratingRepos, 777 ProfileStats: pages.ProfileStats{ 778 Followers: followers, 779 Following: following, 780 }, 781 FollowStatus: db.FollowStatus(followStatus), 782 DidHandleMap: didHandleMap, 783 AvatarUri: profileAvatarUri, 784 }) 785} 786 787func GetAvatarUri(handle string) (string, error) { 788 return fmt.Sprintf("https://avatars.dog/%s@webp", handle), nil 789}