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