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