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", "Invalid repo name.")
576 return
577 }
578
579 defaultBranch := r.FormValue("branch")
580 if defaultBranch == "" {
581 defaultBranch = "main"
582 }
583
584 description := r.FormValue("description")
585
586 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
587 if err != nil || !ok {
588 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
589 return
590 }
591
592 existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
593 if err == nil && existingRepo != nil {
594 s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
595 return
596 }
597
598 secret, err := db.GetRegistrationKey(s.db, domain)
599 if err != nil {
600 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
601 return
602 }
603
604 client, err := NewSignedClient(domain, secret, s.config.Dev)
605 if err != nil {
606 s.pages.Notice(w, "repo", "Failed to connect to knot server.")
607 return
608 }
609
610 rkey := s.TID()
611 repo := &db.Repo{
612 Did: user.Did,
613 Name: repoName,
614 Knot: domain,
615 Rkey: rkey,
616 Description: description,
617 }
618
619 xrpcClient, _ := s.auth.AuthorizedClient(r)
620
621 addedAt := time.Now().Format(time.RFC3339)
622 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
623 Collection: tangled.RepoNSID,
624 Repo: user.Did,
625 Rkey: rkey,
626 Record: &lexutil.LexiconTypeDecoder{
627 Val: &tangled.Repo{
628 Knot: repo.Knot,
629 Name: repoName,
630 AddedAt: &addedAt,
631 Owner: user.Did,
632 }},
633 })
634 if err != nil {
635 log.Printf("failed to create record: %s", err)
636 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
637 return
638 }
639 log.Println("created repo record: ", atresp.Uri)
640
641 tx, err := s.db.BeginTx(r.Context(), nil)
642 if err != nil {
643 log.Println(err)
644 s.pages.Notice(w, "repo", "Failed to save repository information.")
645 return
646 }
647 defer func() {
648 tx.Rollback()
649 err = s.enforcer.E.LoadPolicy()
650 if err != nil {
651 log.Println("failed to rollback policies")
652 }
653 }()
654
655 resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
656 if err != nil {
657 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
658 return
659 }
660
661 switch resp.StatusCode {
662 case http.StatusConflict:
663 s.pages.Notice(w, "repo", "A repository with that name already exists.")
664 return
665 case http.StatusInternalServerError:
666 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
667 case http.StatusNoContent:
668 // continue
669 }
670
671 repo.AtUri = atresp.Uri
672 err = db.AddRepo(tx, repo)
673 if err != nil {
674 log.Println(err)
675 s.pages.Notice(w, "repo", "Failed to save repository information.")
676 return
677 }
678
679 // acls
680 p, _ := securejoin.SecureJoin(user.Did, repoName)
681 err = s.enforcer.AddRepo(user.Did, domain, p)
682 if err != nil {
683 log.Println(err)
684 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
685 return
686 }
687
688 err = tx.Commit()
689 if err != nil {
690 log.Println("failed to commit changes", err)
691 http.Error(w, err.Error(), http.StatusInternalServerError)
692 return
693 }
694
695 err = s.enforcer.E.SavePolicy()
696 if err != nil {
697 log.Println("failed to update ACLs", err)
698 http.Error(w, err.Error(), http.StatusInternalServerError)
699 return
700 }
701
702 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
703 return
704 }
705}
706
707func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
708 didOrHandle := chi.URLParam(r, "user")
709 if didOrHandle == "" {
710 http.Error(w, "Bad request", http.StatusBadRequest)
711 return
712 }
713
714 ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle)
715 if err != nil {
716 log.Printf("resolving identity: %s", err)
717 w.WriteHeader(http.StatusNotFound)
718 return
719 }
720
721 repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
722 if err != nil {
723 log.Printf("getting repos for %s: %s", ident.DID.String(), err)
724 }
725
726 collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
727 if err != nil {
728 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
729 }
730 var didsToResolve []string
731 for _, r := range collaboratingRepos {
732 didsToResolve = append(didsToResolve, r.Did)
733 }
734 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
735 didHandleMap := make(map[string]string)
736 for _, identity := range resolvedIds {
737 if !identity.Handle.IsInvalidHandle() {
738 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
739 } else {
740 didHandleMap[identity.DID.String()] = identity.DID.String()
741 }
742 }
743
744 followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
745 if err != nil {
746 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
747 }
748
749 loggedInUser := s.auth.GetUser(r)
750 followStatus := db.IsNotFollowing
751 if loggedInUser != nil {
752 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
753 }
754
755 profileAvatarUri, err := GetAvatarUri(ident.DID.String())
756 if err != nil {
757 log.Println("failed to fetch bsky avatar", err)
758 }
759
760 s.pages.ProfilePage(w, pages.ProfilePageParams{
761 LoggedInUser: loggedInUser,
762 UserDid: ident.DID.String(),
763 UserHandle: ident.Handle.String(),
764 Repos: repos,
765 CollaboratingRepos: collaboratingRepos,
766 ProfileStats: pages.ProfileStats{
767 Followers: followers,
768 Following: following,
769 },
770 FollowStatus: db.FollowStatus(followStatus),
771 DidHandleMap: didHandleMap,
772 AvatarUri: profileAvatarUri,
773 })
774}
775
776func GetAvatarUri(did string) (string, error) {
777 recordURL := fmt.Sprintf("https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=%s&collection=app.bsky.actor.profile&rkey=self", did)
778
779 recordResp, err := http.Get(recordURL)
780 if err != nil {
781 return "", err
782 }
783 defer recordResp.Body.Close()
784
785 if recordResp.StatusCode != http.StatusOK {
786 return "", fmt.Errorf("getRecord API returned status code %d", recordResp.StatusCode)
787 }
788
789 var profileResp map[string]any
790 if err := json.NewDecoder(recordResp.Body).Decode(&profileResp); err != nil {
791 return "", err
792 }
793
794 value, ok := profileResp["value"].(map[string]any)
795 if !ok {
796 log.Println(profileResp)
797 return "", fmt.Errorf("no value found for handle %s", did)
798 }
799
800 avatar, ok := value["avatar"].(map[string]any)
801 if !ok {
802 log.Println(profileResp)
803 return "", fmt.Errorf("no avatar found for handle %s", did)
804 }
805
806 blobRef, ok := avatar["ref"].(map[string]any)
807 if !ok {
808 log.Println(profileResp)
809 return "", fmt.Errorf("no ref found for handle %s", did)
810 }
811
812 link, ok := blobRef["$link"].(string)
813 if !ok {
814 log.Println(profileResp)
815 return "", fmt.Errorf("no link found for handle %s", did)
816 }
817
818 return fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s", did, link), nil
819}
820
821func (s *State) Router() http.Handler {
822 router := chi.NewRouter()
823
824 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
825 pat := chi.URLParam(r, "*")
826 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
827 s.UserRouter().ServeHTTP(w, r)
828 } else {
829 s.StandardRouter().ServeHTTP(w, r)
830 }
831 })
832
833 return router
834}
835
836func (s *State) UserRouter() http.Handler {
837 r := chi.NewRouter()
838
839 // strip @ from user
840 r.Use(StripLeadingAt)
841
842 r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) {
843 r.Get("/", s.ProfilePage)
844 r.With(ResolveRepoKnot(s)).Route("/{repo}", func(r chi.Router) {
845 r.Get("/", s.RepoIndex)
846 r.Get("/commits/{ref}", s.RepoLog)
847 r.Route("/tree/{ref}", func(r chi.Router) {
848 r.Get("/", s.RepoIndex)
849 r.Get("/*", s.RepoTree)
850 })
851 r.Get("/commit/{ref}", s.RepoCommit)
852 r.Get("/branches", s.RepoBranches)
853 r.Get("/tags", s.RepoTags)
854 r.Get("/blob/{ref}/*", s.RepoBlob)
855
856 r.Route("/issues", func(r chi.Router) {
857 r.Get("/", s.RepoIssues)
858 r.Get("/{issue}", s.RepoSingleIssue)
859
860 r.Group(func(r chi.Router) {
861 r.Use(AuthMiddleware(s))
862 r.Get("/new", s.NewIssue)
863 r.Post("/new", s.NewIssue)
864 r.Post("/{issue}/comment", s.IssueComment)
865 r.Post("/{issue}/close", s.CloseIssue)
866 r.Post("/{issue}/reopen", s.ReopenIssue)
867 })
868 })
869
870 r.Route("/pulls", func(r chi.Router) {
871 r.Get("/", s.RepoPulls)
872 })
873
874 // These routes get proxied to the knot
875 r.Get("/info/refs", s.InfoRefs)
876 r.Post("/git-upload-pack", s.UploadPack)
877
878 // settings routes, needs auth
879 r.Group(func(r chi.Router) {
880 r.Use(AuthMiddleware(s))
881 // repo description can only be edited by owner
882 r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) {
883 r.Put("/", s.RepoDescription)
884 r.Get("/", s.RepoDescription)
885 r.Get("/edit", s.RepoDescriptionEdit)
886 })
887 r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) {
888 r.Get("/", s.RepoSettings)
889 r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator)
890 })
891 })
892 })
893 })
894
895 r.NotFound(func(w http.ResponseWriter, r *http.Request) {
896 s.pages.Error404(w)
897 })
898
899 return r
900}
901
902func (s *State) StandardRouter() http.Handler {
903 r := chi.NewRouter()
904
905 r.Handle("/static/*", s.pages.Static())
906
907 r.Get("/", s.Timeline)
908
909 r.With(AuthMiddleware(s)).Get("/logout", s.Logout)
910
911 r.Route("/login", func(r chi.Router) {
912 r.Get("/", s.Login)
913 r.Post("/", s.Login)
914 })
915
916 r.Route("/knots", func(r chi.Router) {
917 r.Use(AuthMiddleware(s))
918 r.Get("/", s.Knots)
919 r.Post("/key", s.RegistrationKey)
920
921 r.Route("/{domain}", func(r chi.Router) {
922 r.Post("/init", s.InitKnotServer)
923 r.Get("/", s.KnotServerInfo)
924 r.Route("/member", func(r chi.Router) {
925 r.Use(RoleMiddleware(s, "server:owner"))
926 r.Get("/", s.ListMembers)
927 r.Put("/", s.AddMember)
928 r.Delete("/", s.RemoveMember)
929 })
930 })
931 })
932
933 r.Route("/repo", func(r chi.Router) {
934 r.Route("/new", func(r chi.Router) {
935 r.Use(AuthMiddleware(s))
936 r.Get("/", s.NewRepo)
937 r.Post("/", s.NewRepo)
938 })
939 // r.Post("/import", s.ImportRepo)
940 })
941
942 r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) {
943 r.Post("/", s.Follow)
944 r.Delete("/", s.Follow)
945 })
946
947 r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) {
948 r.Post("/", s.Star)
949 r.Delete("/", s.Star)
950 })
951
952 r.Route("/settings", func(r chi.Router) {
953 r.Use(AuthMiddleware(s))
954 r.Get("/", s.Settings)
955 r.Put("/keys", s.SettingsKeys)
956 })
957
958 r.Get("/keys/{user}", s.Keys)
959
960 r.NotFound(func(w http.ResponseWriter, r *http.Request) {
961 s.pages.Error404(w)
962 })
963 return r
964}