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