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 w.Header().Set("HX-Redirect", "/login")
169 w.WriteHeader(http.StatusSeeOther)
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 var didsToResolve []string
424 for _, m := range members {
425 didsToResolve = append(didsToResolve, m)
426 }
427 didsToResolve = append(didsToResolve, reg.ByDid)
428 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
429 didHandleMap := make(map[string]string)
430 for _, identity := range resolvedIds {
431 if !identity.Handle.IsInvalidHandle() {
432 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
433 } else {
434 didHandleMap[identity.DID.String()] = identity.DID.String()
435 }
436 }
437
438 ok, err := s.enforcer.IsServerOwner(user.Did, domain)
439 isOwner := err == nil && ok
440
441 p := pages.KnotParams{
442 LoggedInUser: user,
443 DidHandleMap: didHandleMap,
444 Registration: reg,
445 Members: members,
446 IsOwner: isOwner,
447 }
448
449 s.pages.Knot(w, p)
450}
451
452// get knots registered by this user
453func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
454 // for now, this is just pubkeys
455 user := s.auth.GetUser(r)
456 registrations, err := db.RegistrationsByDid(s.db, user.Did)
457 if err != nil {
458 log.Println(err)
459 }
460
461 s.pages.Knots(w, pages.KnotsParams{
462 LoggedInUser: user,
463 Registrations: registrations,
464 })
465}
466
467// list members of domain, requires auth and requires owner status
468func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) {
469 domain := chi.URLParam(r, "domain")
470 if domain == "" {
471 http.Error(w, "malformed url", http.StatusBadRequest)
472 return
473 }
474
475 // list all members for this domain
476 memberDids, err := s.enforcer.GetUserByRole("server:member", domain)
477 if err != nil {
478 w.Write([]byte("failed to fetch member list"))
479 return
480 }
481
482 w.Write([]byte(strings.Join(memberDids, "\n")))
483 return
484}
485
486// add member to domain, requires auth and requires invite access
487func (s *State) AddMember(w http.ResponseWriter, r *http.Request) {
488 domain := chi.URLParam(r, "domain")
489 if domain == "" {
490 http.Error(w, "malformed url", http.StatusBadRequest)
491 return
492 }
493
494 memberDid := r.FormValue("member")
495 if memberDid == "" {
496 http.Error(w, "malformed form", http.StatusBadRequest)
497 return
498 }
499
500 memberIdent, err := s.resolver.ResolveIdent(r.Context(), memberDid)
501 if err != nil {
502 w.Write([]byte("failed to resolve member did to a handle"))
503 return
504 }
505 log.Printf("adding %s to %s\n", memberIdent.Handle.String(), domain)
506
507 // announce this relation into the firehose, store into owners' pds
508 client, _ := s.auth.AuthorizedClient(r)
509 currentUser := s.auth.GetUser(r)
510 addedAt := time.Now().Format(time.RFC3339)
511 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
512 Collection: tangled.KnotMemberNSID,
513 Repo: currentUser.Did,
514 Rkey: s.TID(),
515 Record: &lexutil.LexiconTypeDecoder{
516 Val: &tangled.KnotMember{
517 Member: memberIdent.DID.String(),
518 Domain: domain,
519 AddedAt: &addedAt,
520 }},
521 })
522
523 // invalid record
524 if err != nil {
525 log.Printf("failed to create record: %s", err)
526 return
527 }
528 log.Println("created atproto record: ", resp.Uri)
529
530 secret, err := db.GetRegistrationKey(s.db, domain)
531 if err != nil {
532 log.Printf("no key found for domain %s: %s\n", domain, err)
533 return
534 }
535
536 ksClient, err := NewSignedClient(domain, secret, s.config.Dev)
537 if err != nil {
538 log.Println("failed to create client to ", domain)
539 return
540 }
541
542 ksResp, err := ksClient.AddMember(memberIdent.DID.String())
543 if err != nil {
544 log.Printf("failed to make request to %s: %s", domain, err)
545 return
546 }
547
548 if ksResp.StatusCode != http.StatusNoContent {
549 w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err)))
550 return
551 }
552
553 err = s.enforcer.AddMember(domain, memberIdent.DID.String())
554 if err != nil {
555 w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
556 return
557 }
558
559 w.Write([]byte(fmt.Sprint("added member: ", memberIdent.Handle.String())))
560}
561
562func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
563}
564
565func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
566 switch r.Method {
567 case http.MethodGet:
568 user := s.auth.GetUser(r)
569 knots, err := s.enforcer.GetDomainsForUser(user.Did)
570 if err != nil {
571 s.pages.Notice(w, "repo", "Invalid user account.")
572 return
573 }
574
575 s.pages.NewRepo(w, pages.NewRepoParams{
576 LoggedInUser: user,
577 Knots: knots,
578 })
579
580 case http.MethodPost:
581 user := s.auth.GetUser(r)
582
583 domain := r.FormValue("domain")
584 if domain == "" {
585 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
586 return
587 }
588
589 repoName := r.FormValue("name")
590 if repoName == "" {
591 s.pages.Notice(w, "repo", "Repository name cannot be empty.")
592 return
593 }
594
595 // Check for valid repository name (GitHub-like rules)
596 // No spaces, only alphanumeric characters, dashes, and underscores
597 for _, char := range repoName {
598 if !((char >= 'a' && char <= 'z') ||
599 (char >= 'A' && char <= 'Z') ||
600 (char >= '0' && char <= '9') ||
601 char == '-' || char == '_' || char == '.') {
602 s.pages.Notice(w, "repo", "Repository name can only contain alphanumeric characters, periods, hyphens, and underscores.")
603 return
604 }
605 }
606
607 defaultBranch := r.FormValue("branch")
608 if defaultBranch == "" {
609 defaultBranch = "main"
610 }
611
612 description := r.FormValue("description")
613
614 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
615 if err != nil || !ok {
616 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
617 return
618 }
619
620 existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
621 if err == nil && existingRepo != nil {
622 s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
623 return
624 }
625
626 secret, err := db.GetRegistrationKey(s.db, domain)
627 if err != nil {
628 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
629 return
630 }
631
632 client, err := NewSignedClient(domain, secret, s.config.Dev)
633 if err != nil {
634 s.pages.Notice(w, "repo", "Failed to connect to knot server.")
635 return
636 }
637
638 rkey := s.TID()
639 repo := &db.Repo{
640 Did: user.Did,
641 Name: repoName,
642 Knot: domain,
643 Rkey: rkey,
644 Description: description,
645 }
646
647 xrpcClient, _ := s.auth.AuthorizedClient(r)
648
649 addedAt := time.Now().Format(time.RFC3339)
650 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
651 Collection: tangled.RepoNSID,
652 Repo: user.Did,
653 Rkey: rkey,
654 Record: &lexutil.LexiconTypeDecoder{
655 Val: &tangled.Repo{
656 Knot: repo.Knot,
657 Name: repoName,
658 AddedAt: &addedAt,
659 Owner: user.Did,
660 }},
661 })
662 if err != nil {
663 log.Printf("failed to create record: %s", err)
664 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
665 return
666 }
667 log.Println("created repo record: ", atresp.Uri)
668
669 tx, err := s.db.BeginTx(r.Context(), nil)
670 if err != nil {
671 log.Println(err)
672 s.pages.Notice(w, "repo", "Failed to save repository information.")
673 return
674 }
675 defer func() {
676 tx.Rollback()
677 err = s.enforcer.E.LoadPolicy()
678 if err != nil {
679 log.Println("failed to rollback policies")
680 }
681 }()
682
683 resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
684 if err != nil {
685 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
686 return
687 }
688
689 switch resp.StatusCode {
690 case http.StatusConflict:
691 s.pages.Notice(w, "repo", "A repository with that name already exists.")
692 return
693 case http.StatusInternalServerError:
694 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
695 case http.StatusNoContent:
696 // continue
697 }
698
699 repo.AtUri = atresp.Uri
700 err = db.AddRepo(tx, repo)
701 if err != nil {
702 log.Println(err)
703 s.pages.Notice(w, "repo", "Failed to save repository information.")
704 return
705 }
706
707 // acls
708 p, _ := securejoin.SecureJoin(user.Did, repoName)
709 err = s.enforcer.AddRepo(user.Did, domain, p)
710 if err != nil {
711 log.Println(err)
712 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
713 return
714 }
715
716 err = tx.Commit()
717 if err != nil {
718 log.Println("failed to commit changes", err)
719 http.Error(w, err.Error(), http.StatusInternalServerError)
720 return
721 }
722
723 err = s.enforcer.E.SavePolicy()
724 if err != nil {
725 log.Println("failed to update ACLs", err)
726 http.Error(w, err.Error(), http.StatusInternalServerError)
727 return
728 }
729
730 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
731 return
732 }
733}
734
735func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
736 didOrHandle := chi.URLParam(r, "user")
737 if didOrHandle == "" {
738 http.Error(w, "Bad request", http.StatusBadRequest)
739 return
740 }
741
742 ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle)
743 if err != nil {
744 log.Printf("resolving identity: %s", err)
745 w.WriteHeader(http.StatusNotFound)
746 return
747 }
748
749 repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
750 if err != nil {
751 log.Printf("getting repos for %s: %s", ident.DID.String(), err)
752 }
753
754 collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
755 if err != nil {
756 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
757 }
758 var didsToResolve []string
759 for _, r := range collaboratingRepos {
760 didsToResolve = append(didsToResolve, r.Did)
761 }
762 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
763 didHandleMap := make(map[string]string)
764 for _, identity := range resolvedIds {
765 if !identity.Handle.IsInvalidHandle() {
766 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
767 } else {
768 didHandleMap[identity.DID.String()] = identity.DID.String()
769 }
770 }
771
772 followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
773 if err != nil {
774 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
775 }
776
777 loggedInUser := s.auth.GetUser(r)
778 followStatus := db.IsNotFollowing
779 if loggedInUser != nil {
780 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
781 }
782
783 profileAvatarUri, err := GetAvatarUri(ident.Handle.String())
784 if err != nil {
785 log.Println("failed to fetch bsky avatar", err)
786 }
787
788 s.pages.ProfilePage(w, pages.ProfilePageParams{
789 LoggedInUser: loggedInUser,
790 UserDid: ident.DID.String(),
791 UserHandle: ident.Handle.String(),
792 Repos: repos,
793 CollaboratingRepos: collaboratingRepos,
794 ProfileStats: pages.ProfileStats{
795 Followers: followers,
796 Following: following,
797 },
798 FollowStatus: db.FollowStatus(followStatus),
799 DidHandleMap: didHandleMap,
800 AvatarUri: profileAvatarUri,
801 })
802}
803
804func GetAvatarUri(handle string) (string, error) {
805 return fmt.Sprintf("https://avatars.dog/%s@webp", handle), nil
806}