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)
188 didsToResolve = append(didsToResolve, ev.Follow.SubjectDid)
189 }
190 }
191
192 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
193 didHandleMap := make(map[string]string)
194 for _, identity := range resolvedIds {
195 if !identity.Handle.IsInvalidHandle() {
196 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
197 } else {
198 didHandleMap[identity.DID.String()] = identity.DID.String()
199 }
200 }
201
202 s.pages.Timeline(w, pages.TimelineParams{
203 LoggedInUser: user,
204 Timeline: timeline,
205 DidHandleMap: didHandleMap,
206 })
207
208 return
209}
210
211// requires auth
212func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) {
213 switch r.Method {
214 case http.MethodGet:
215 // list open registrations under this did
216
217 return
218 case http.MethodPost:
219 session, err := s.auth.Store.Get(r, appview.SessionName)
220 if err != nil || session.IsNew {
221 log.Println("unauthorized attempt to generate registration key")
222 http.Error(w, "Forbidden", http.StatusUnauthorized)
223 return
224 }
225
226 did := session.Values[appview.SessionDid].(string)
227
228 // check if domain is valid url, and strip extra bits down to just host
229 domain := r.FormValue("domain")
230 if domain == "" {
231 http.Error(w, "Invalid form", http.StatusBadRequest)
232 return
233 }
234
235 key, err := db.GenerateRegistrationKey(s.db, domain, did)
236
237 if err != nil {
238 log.Println(err)
239 http.Error(w, "unable to register this domain", http.StatusNotAcceptable)
240 return
241 }
242
243 w.Write([]byte(key))
244 }
245}
246
247func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
248 user := chi.URLParam(r, "user")
249 user = strings.TrimPrefix(user, "@")
250
251 if user == "" {
252 w.WriteHeader(http.StatusBadRequest)
253 return
254 }
255
256 id, err := s.resolver.ResolveIdent(r.Context(), user)
257 if err != nil {
258 w.WriteHeader(http.StatusInternalServerError)
259 return
260 }
261
262 pubKeys, err := db.GetPublicKeys(s.db, id.DID.String())
263 if err != nil {
264 w.WriteHeader(http.StatusNotFound)
265 return
266 }
267
268 if len(pubKeys) == 0 {
269 w.WriteHeader(http.StatusNotFound)
270 return
271 }
272
273 for _, k := range pubKeys {
274 key := strings.TrimRight(k.Key, "\n")
275 w.Write([]byte(fmt.Sprintln(key)))
276 }
277}
278
279// create a signed request and check if a node responds to that
280func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) {
281 user := s.auth.GetUser(r)
282
283 domain := chi.URLParam(r, "domain")
284 if domain == "" {
285 http.Error(w, "malformed url", http.StatusBadRequest)
286 return
287 }
288 log.Println("checking ", domain)
289
290 secret, err := db.GetRegistrationKey(s.db, domain)
291 if err != nil {
292 log.Printf("no key found for domain %s: %s\n", domain, err)
293 return
294 }
295
296 client, err := NewSignedClient(domain, secret, s.config.Dev)
297 if err != nil {
298 log.Println("failed to create client to ", domain)
299 }
300
301 resp, err := client.Init(user.Did)
302 if err != nil {
303 w.Write([]byte("no dice"))
304 log.Println("domain was unreachable after 5 seconds")
305 return
306 }
307
308 if resp.StatusCode == http.StatusConflict {
309 log.Println("status conflict", resp.StatusCode)
310 w.Write([]byte("already registered, sorry!"))
311 return
312 }
313
314 if resp.StatusCode != http.StatusNoContent {
315 log.Println("status nok", resp.StatusCode)
316 w.Write([]byte("no dice"))
317 return
318 }
319
320 // verify response mac
321 signature := resp.Header.Get("X-Signature")
322 signatureBytes, err := hex.DecodeString(signature)
323 if err != nil {
324 return
325 }
326
327 expectedMac := hmac.New(sha256.New, []byte(secret))
328 expectedMac.Write([]byte("ok"))
329
330 if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
331 log.Printf("response body signature mismatch: %x\n", signatureBytes)
332 return
333 }
334
335 tx, err := s.db.BeginTx(r.Context(), nil)
336 if err != nil {
337 log.Println("failed to start tx", err)
338 http.Error(w, err.Error(), http.StatusInternalServerError)
339 return
340 }
341 defer func() {
342 tx.Rollback()
343 err = s.enforcer.E.LoadPolicy()
344 if err != nil {
345 log.Println("failed to rollback policies")
346 }
347 }()
348
349 // mark as registered
350 err = db.Register(tx, domain)
351 if err != nil {
352 log.Println("failed to register domain", err)
353 http.Error(w, err.Error(), http.StatusInternalServerError)
354 return
355 }
356
357 // set permissions for this did as owner
358 reg, err := db.RegistrationByDomain(tx, domain)
359 if err != nil {
360 log.Println("failed to register domain", err)
361 http.Error(w, err.Error(), http.StatusInternalServerError)
362 return
363 }
364
365 // add basic acls for this domain
366 err = s.enforcer.AddDomain(domain)
367 if err != nil {
368 log.Println("failed to setup owner of domain", err)
369 http.Error(w, err.Error(), http.StatusInternalServerError)
370 return
371 }
372
373 // add this did as owner of this domain
374 err = s.enforcer.AddOwner(domain, reg.ByDid)
375 if err != nil {
376 log.Println("failed to setup owner of domain", err)
377 http.Error(w, err.Error(), http.StatusInternalServerError)
378 return
379 }
380
381 err = tx.Commit()
382 if err != nil {
383 log.Println("failed to commit changes", err)
384 http.Error(w, err.Error(), http.StatusInternalServerError)
385 return
386 }
387
388 err = s.enforcer.E.SavePolicy()
389 if err != nil {
390 log.Println("failed to update ACLs", err)
391 http.Error(w, err.Error(), http.StatusInternalServerError)
392 return
393 }
394
395 w.Write([]byte("check success"))
396}
397
398func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) {
399 domain := chi.URLParam(r, "domain")
400 if domain == "" {
401 http.Error(w, "malformed url", http.StatusBadRequest)
402 return
403 }
404
405 user := s.auth.GetUser(r)
406 reg, err := db.RegistrationByDomain(s.db, domain)
407 if err != nil {
408 w.Write([]byte("failed to pull up registration info"))
409 return
410 }
411
412 var members []string
413 if reg.Registered != nil {
414 members, err = s.enforcer.GetUserByRole("server:member", domain)
415 if err != nil {
416 w.Write([]byte("failed to fetch member list"))
417 return
418 }
419 }
420
421 ok, err := s.enforcer.IsServerOwner(user.Did, domain)
422 isOwner := err == nil && ok
423
424 p := pages.KnotParams{
425 LoggedInUser: user,
426 Registration: reg,
427 Members: members,
428 IsOwner: isOwner,
429 }
430
431 s.pages.Knot(w, p)
432}
433
434// get knots registered by this user
435func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
436 // for now, this is just pubkeys
437 user := s.auth.GetUser(r)
438 registrations, err := db.RegistrationsByDid(s.db, user.Did)
439 if err != nil {
440 log.Println(err)
441 }
442
443 s.pages.Knots(w, pages.KnotsParams{
444 LoggedInUser: user,
445 Registrations: registrations,
446 })
447}
448
449// list members of domain, requires auth and requires owner status
450func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) {
451 domain := chi.URLParam(r, "domain")
452 if domain == "" {
453 http.Error(w, "malformed url", http.StatusBadRequest)
454 return
455 }
456
457 // list all members for this domain
458 memberDids, err := s.enforcer.GetUserByRole("server:member", domain)
459 if err != nil {
460 w.Write([]byte("failed to fetch member list"))
461 return
462 }
463
464 w.Write([]byte(strings.Join(memberDids, "\n")))
465 return
466}
467
468// add member to domain, requires auth and requires invite access
469func (s *State) AddMember(w http.ResponseWriter, r *http.Request) {
470 domain := chi.URLParam(r, "domain")
471 if domain == "" {
472 http.Error(w, "malformed url", http.StatusBadRequest)
473 return
474 }
475
476 memberDid := r.FormValue("member")
477 if memberDid == "" {
478 http.Error(w, "malformed form", http.StatusBadRequest)
479 return
480 }
481
482 memberIdent, err := s.resolver.ResolveIdent(r.Context(), memberDid)
483 if err != nil {
484 w.Write([]byte("failed to resolve member did to a handle"))
485 return
486 }
487 log.Printf("adding %s to %s\n", memberIdent.Handle.String(), domain)
488
489 // announce this relation into the firehose, store into owners' pds
490 client, _ := s.auth.AuthorizedClient(r)
491 currentUser := s.auth.GetUser(r)
492 addedAt := time.Now().Format(time.RFC3339)
493 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
494 Collection: tangled.KnotMemberNSID,
495 Repo: currentUser.Did,
496 Rkey: s.TID(),
497 Record: &lexutil.LexiconTypeDecoder{
498 Val: &tangled.KnotMember{
499 Member: memberIdent.DID.String(),
500 Domain: domain,
501 AddedAt: &addedAt,
502 }},
503 })
504
505 // invalid record
506 if err != nil {
507 log.Printf("failed to create record: %s", err)
508 return
509 }
510 log.Println("created atproto record: ", resp.Uri)
511
512 secret, err := db.GetRegistrationKey(s.db, domain)
513 if err != nil {
514 log.Printf("no key found for domain %s: %s\n", domain, err)
515 return
516 }
517
518 ksClient, err := NewSignedClient(domain, secret, s.config.Dev)
519 if err != nil {
520 log.Println("failed to create client to ", domain)
521 return
522 }
523
524 ksResp, err := ksClient.AddMember(memberIdent.DID.String())
525 if err != nil {
526 log.Printf("failed to make request to %s: %s", domain, err)
527 return
528 }
529
530 if ksResp.StatusCode != http.StatusNoContent {
531 w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err)))
532 return
533 }
534
535 err = s.enforcer.AddMember(domain, memberIdent.DID.String())
536 if err != nil {
537 w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
538 return
539 }
540
541 w.Write([]byte(fmt.Sprint("added member: ", memberIdent.Handle.String())))
542}
543
544func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
545}
546
547func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
548 switch r.Method {
549 case http.MethodGet:
550 user := s.auth.GetUser(r)
551 knots, err := s.enforcer.GetDomainsForUser(user.Did)
552 if err != nil {
553 s.pages.Notice(w, "repo", "Invalid user account.")
554 return
555 }
556
557 s.pages.NewRepo(w, pages.NewRepoParams{
558 LoggedInUser: user,
559 Knots: knots,
560 })
561
562 case http.MethodPost:
563 user := s.auth.GetUser(r)
564
565 domain := r.FormValue("domain")
566 if domain == "" {
567 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
568 return
569 }
570
571 repoName := r.FormValue("name")
572 if repoName == "" {
573 s.pages.Notice(w, "repo", "Invalid repo name.")
574 return
575 }
576
577 defaultBranch := r.FormValue("branch")
578 if defaultBranch == "" {
579 defaultBranch = "main"
580 }
581
582 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
583 if err != nil || !ok {
584 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
585 return
586 }
587
588 existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
589 if err == nil && existingRepo != nil {
590 s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
591 return
592 }
593
594 secret, err := db.GetRegistrationKey(s.db, domain)
595 if err != nil {
596 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
597 return
598 }
599
600 client, err := NewSignedClient(domain, secret, s.config.Dev)
601 if err != nil {
602 s.pages.Notice(w, "repo", "Failed to connect to knot server.")
603 return
604 }
605
606 rkey := s.TID()
607 repo := &db.Repo{
608 Did: user.Did,
609 Name: repoName,
610 Knot: domain,
611 Rkey: rkey,
612 }
613
614 xrpcClient, _ := s.auth.AuthorizedClient(r)
615
616 addedAt := time.Now().Format(time.RFC3339)
617 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
618 Collection: tangled.RepoNSID,
619 Repo: user.Did,
620 Rkey: rkey,
621 Record: &lexutil.LexiconTypeDecoder{
622 Val: &tangled.Repo{
623 Knot: repo.Knot,
624 Name: repoName,
625 AddedAt: &addedAt,
626 Owner: user.Did,
627 }},
628 })
629 if err != nil {
630 log.Printf("failed to create record: %s", err)
631 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
632 return
633 }
634 log.Println("created repo record: ", atresp.Uri)
635
636 tx, err := s.db.BeginTx(r.Context(), nil)
637 if err != nil {
638 log.Println(err)
639 s.pages.Notice(w, "repo", "Failed to save repository information.")
640 return
641 }
642 defer func() {
643 tx.Rollback()
644 err = s.enforcer.E.LoadPolicy()
645 if err != nil {
646 log.Println("failed to rollback policies")
647 }
648 }()
649
650 resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
651 if err != nil {
652 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
653 return
654 }
655
656 switch resp.StatusCode {
657 case http.StatusConflict:
658 s.pages.Notice(w, "repo", "A repository with that name already exists.")
659 return
660 case http.StatusInternalServerError:
661 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
662 case http.StatusNoContent:
663 // continue
664 }
665
666 repo.AtUri = atresp.Uri
667 err = db.AddRepo(tx, repo)
668 if err != nil {
669 log.Println(err)
670 s.pages.Notice(w, "repo", "Failed to save repository information.")
671 return
672 }
673
674 // acls
675 p, _ := securejoin.SecureJoin(user.Did, repoName)
676 err = s.enforcer.AddRepo(user.Did, domain, p)
677 if err != nil {
678 log.Println(err)
679 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
680 return
681 }
682
683 err = tx.Commit()
684 if err != nil {
685 log.Println("failed to commit changes", err)
686 http.Error(w, err.Error(), http.StatusInternalServerError)
687 return
688 }
689
690 err = s.enforcer.E.SavePolicy()
691 if err != nil {
692 log.Println("failed to update ACLs", err)
693 http.Error(w, err.Error(), http.StatusInternalServerError)
694 return
695 }
696
697 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
698 return
699 }
700}
701
702func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
703 didOrHandle := chi.URLParam(r, "user")
704 if didOrHandle == "" {
705 http.Error(w, "Bad request", http.StatusBadRequest)
706 return
707 }
708
709 ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle)
710 if err != nil {
711 log.Printf("resolving identity: %s", err)
712 w.WriteHeader(http.StatusNotFound)
713 return
714 }
715
716 repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
717 if err != nil {
718 log.Printf("getting repos for %s: %s", ident.DID.String(), err)
719 }
720
721 collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
722 if err != nil {
723 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
724 }
725 var didsToResolve []string
726 for _, r := range collaboratingRepos {
727 didsToResolve = append(didsToResolve, r.Did)
728 }
729 resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
730 didHandleMap := make(map[string]string)
731 for _, identity := range resolvedIds {
732 if !identity.Handle.IsInvalidHandle() {
733 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
734 } else {
735 didHandleMap[identity.DID.String()] = identity.DID.String()
736 }
737 }
738
739 followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
740 if err != nil {
741 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
742 }
743
744 loggedInUser := s.auth.GetUser(r)
745 followStatus := db.IsNotFollowing
746 if loggedInUser != nil {
747 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
748 }
749
750 profileAvatarUri, err := GetAvatarUri(ident.DID.String())
751 if err != nil {
752 log.Println("failed to fetch bsky avatar", err)
753 }
754
755 s.pages.ProfilePage(w, pages.ProfilePageParams{
756 LoggedInUser: loggedInUser,
757 UserDid: ident.DID.String(),
758 UserHandle: ident.Handle.String(),
759 Repos: repos,
760 CollaboratingRepos: collaboratingRepos,
761 ProfileStats: pages.ProfileStats{
762 Followers: followers,
763 Following: following,
764 },
765 FollowStatus: db.FollowStatus(followStatus),
766 DidHandleMap: didHandleMap,
767 AvatarUri: profileAvatarUri,
768 })
769}
770
771func GetAvatarUri(did string) (string, error) {
772 recordURL := fmt.Sprintf("https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=%s&collection=app.bsky.actor.profile&rkey=self", did)
773
774 recordResp, err := http.Get(recordURL)
775 if err != nil {
776 return "", err
777 }
778 defer recordResp.Body.Close()
779
780 if recordResp.StatusCode != http.StatusOK {
781 return "", fmt.Errorf("getRecord API returned status code %d", recordResp.StatusCode)
782 }
783
784 var profileResp map[string]any
785 if err := json.NewDecoder(recordResp.Body).Decode(&profileResp); err != nil {
786 return "", err
787 }
788
789 value, ok := profileResp["value"].(map[string]any)
790 if !ok {
791 log.Println(profileResp)
792 return "", fmt.Errorf("no value found for handle %s", did)
793 }
794
795 avatar, ok := value["avatar"].(map[string]any)
796 if !ok {
797 log.Println(profileResp)
798 return "", fmt.Errorf("no avatar found for handle %s", did)
799 }
800
801 blobRef, ok := avatar["ref"].(map[string]any)
802 if !ok {
803 log.Println(profileResp)
804 return "", fmt.Errorf("no ref found for handle %s", did)
805 }
806
807 link, ok := blobRef["$link"].(string)
808 if !ok {
809 log.Println(profileResp)
810 return "", fmt.Errorf("no link found for handle %s", did)
811 }
812
813 return fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s", did, link), nil
814}
815
816func (s *State) Router() http.Handler {
817 router := chi.NewRouter()
818
819 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
820 pat := chi.URLParam(r, "*")
821 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
822 s.UserRouter().ServeHTTP(w, r)
823 } else {
824 s.StandardRouter().ServeHTTP(w, r)
825 }
826 })
827
828 return router
829}
830
831func (s *State) UserRouter() http.Handler {
832 r := chi.NewRouter()
833
834 // strip @ from user
835 r.Use(StripLeadingAt)
836
837 r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) {
838 r.Get("/", s.ProfilePage)
839 r.With(ResolveRepoKnot(s)).Route("/{repo}", func(r chi.Router) {
840 r.Get("/", s.RepoIndex)
841 r.Get("/commits/{ref}", s.RepoLog)
842 r.Route("/tree/{ref}", func(r chi.Router) {
843 r.Get("/", s.RepoIndex)
844 r.Get("/*", s.RepoTree)
845 })
846 r.Get("/commit/{ref}", s.RepoCommit)
847 r.Get("/branches", s.RepoBranches)
848 r.Get("/tags", s.RepoTags)
849 r.Get("/blob/{ref}/*", s.RepoBlob)
850
851 r.Route("/issues", func(r chi.Router) {
852 r.Get("/", s.RepoIssues)
853 r.Get("/{issue}", s.RepoSingleIssue)
854
855 r.Group(func(r chi.Router) {
856 r.Use(AuthMiddleware(s))
857 r.Get("/new", s.NewIssue)
858 r.Post("/new", s.NewIssue)
859 r.Post("/{issue}/comment", s.IssueComment)
860 r.Post("/{issue}/close", s.CloseIssue)
861 r.Post("/{issue}/reopen", s.ReopenIssue)
862 })
863 })
864
865 r.Route("/pulls", func(r chi.Router) {
866 r.Get("/", s.RepoPulls)
867 })
868
869 // These routes get proxied to the knot
870 r.Get("/info/refs", s.InfoRefs)
871 r.Post("/git-upload-pack", s.UploadPack)
872
873 // settings routes, needs auth
874 r.Group(func(r chi.Router) {
875 r.Use(AuthMiddleware(s))
876 r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) {
877 r.Get("/", s.RepoSettings)
878 r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator)
879 })
880 })
881 })
882 })
883
884 r.NotFound(func(w http.ResponseWriter, r *http.Request) {
885 s.pages.Error404(w)
886 })
887
888 return r
889}
890
891func (s *State) StandardRouter() http.Handler {
892 r := chi.NewRouter()
893
894 r.Handle("/static/*", s.pages.Static())
895
896 r.Get("/", s.Timeline)
897
898 r.With(AuthMiddleware(s)).Get("/logout", s.Logout)
899
900 r.Route("/login", func(r chi.Router) {
901 r.Get("/", s.Login)
902 r.Post("/", s.Login)
903 })
904
905 r.Route("/knots", func(r chi.Router) {
906 r.Use(AuthMiddleware(s))
907 r.Get("/", s.Knots)
908 r.Post("/key", s.RegistrationKey)
909
910 r.Route("/{domain}", func(r chi.Router) {
911 r.Post("/init", s.InitKnotServer)
912 r.Get("/", s.KnotServerInfo)
913 r.Route("/member", func(r chi.Router) {
914 r.Use(RoleMiddleware(s, "server:owner"))
915 r.Get("/", s.ListMembers)
916 r.Put("/", s.AddMember)
917 r.Delete("/", s.RemoveMember)
918 })
919 })
920 })
921
922 r.Route("/repo", func(r chi.Router) {
923 r.Route("/new", func(r chi.Router) {
924 r.Use(AuthMiddleware(s))
925 r.Get("/", s.NewRepo)
926 r.Post("/", s.NewRepo)
927 })
928 // r.Post("/import", s.ImportRepo)
929 })
930
931 r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) {
932 r.Post("/", s.Follow)
933 r.Delete("/", s.Follow)
934 })
935
936 r.Route("/settings", func(r chi.Router) {
937 r.Use(AuthMiddleware(s))
938 r.Get("/", s.Settings)
939 r.Put("/keys", s.SettingsKeys)
940 })
941
942 r.Get("/keys/{user}", s.Keys)
943
944 r.NotFound(func(w http.ResponseWriter, r *http.Request) {
945 s.pages.Error404(w)
946 })
947 return r
948}