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 "github.com/sotangled/tangled/api/tangled"
21 "github.com/sotangled/tangled/appview"
22 "github.com/sotangled/tangled/appview/auth"
23 "github.com/sotangled/tangled/appview/db"
24 "github.com/sotangled/tangled/appview/pages"
25 "github.com/sotangled/tangled/jetstream"
26 "github.com/sotangled/tangled/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—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}