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