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