forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}