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