this repo has no description
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 "github.com/bluesky-social/jetstream/pkg/models"
20 securejoin "github.com/cyphar/filepath-securejoin"
21 "github.com/go-chi/chi/v5"
22 tangled "github.com/sotangled/tangled/api/tangled"
23 "github.com/sotangled/tangled/appview"
24 "github.com/sotangled/tangled/appview/auth"
25 "github.com/sotangled/tangled/appview/db"
26 "github.com/sotangled/tangled/appview/pages"
27 "github.com/sotangled/tangled/jetstream"
28 "github.com/sotangled/tangled/rbac"
29)
30
31type State struct {
32 db *db.DB
33 auth *auth.Auth
34 enforcer *rbac.Enforcer
35 tidClock *syntax.TIDClock
36 pages *pages.Pages
37 resolver *appview.Resolver
38 jc *jetstream.JetstreamClient
39}
40
41func Make() (*State, error) {
42 db, err := db.Make(appview.SqliteDbPath)
43 if err != nil {
44 return nil, err
45 }
46
47 auth, err := auth.Make()
48 if err != nil {
49 return nil, err
50 }
51
52 enforcer, err := rbac.NewEnforcer(appview.SqliteDbPath)
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 jc, err := jetstream.NewJetstreamClient("appview", []string{tangled.GraphFollowNSID}, nil, slog.Default(), db, false)
64 if err != nil {
65 return nil, fmt.Errorf("failed to create jetstream client: %w", err)
66 }
67 err = jc.StartJetstream(context.Background(), func(ctx context.Context, e *models.Event) error {
68 if e.Kind != models.EventKindCommit {
69 return nil
70 }
71
72 did := e.Did
73 fmt.Println("got event", e.Commit.Collection, e.Commit.RKey, e.Commit.Record)
74 raw := json.RawMessage(e.Commit.Record)
75
76 switch e.Commit.Collection {
77 case tangled.GraphFollowNSID:
78 record := tangled.GraphFollow{}
79 err := json.Unmarshal(raw, &record)
80 if err != nil {
81 return err
82 }
83 err = db.AddFollow(did, record.Subject, e.Commit.RKey)
84 if err != nil {
85 return fmt.Errorf("failed to add follow to db: %w", err)
86 }
87 return db.UpdateLastTimeUs(e.TimeUS)
88 }
89
90 return nil
91 })
92 if err != nil {
93 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err)
94 }
95
96 state := &State{
97 db,
98 auth,
99 enforcer,
100 clock,
101 pgs,
102 resolver,
103 jc,
104 }
105
106 return state, nil
107}
108
109func (s *State) TID() string {
110 return s.tidClock.Next().String()
111}
112
113func (s *State) Login(w http.ResponseWriter, r *http.Request) {
114 ctx := r.Context()
115
116 switch r.Method {
117 case http.MethodGet:
118 err := s.pages.Login(w, pages.LoginParams{})
119 if err != nil {
120 log.Printf("rendering login page: %s", err)
121 }
122 return
123 case http.MethodPost:
124 handle := strings.TrimPrefix(r.FormValue("handle"), "@")
125 appPassword := r.FormValue("app_password")
126
127 resolved, err := s.resolver.ResolveIdent(ctx, handle)
128 if err != nil {
129 log.Println("failed to resolve handle:", err)
130 s.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
131 return
132 }
133
134 atSession, err := s.auth.CreateInitialSession(ctx, resolved, appPassword)
135 if err != nil {
136 s.pages.Notice(w, "login-msg", "Invalid handle or password.")
137 return
138 }
139 sessionish := auth.CreateSessionWrapper{ServerCreateSession_Output: atSession}
140
141 err = s.auth.StoreSession(r, w, &sessionish, resolved.PDSEndpoint())
142 if err != nil {
143 s.pages.Notice(w, "login-msg", "Failed to login, try again later.")
144 return
145 }
146
147 log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did)
148 s.pages.HxRedirect(w, "/")
149 return
150 }
151}
152
153func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
154 s.auth.ClearSession(r, w)
155 s.pages.HxRedirect(w, "/")
156}
157
158func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
159 user := s.auth.GetUser(r)
160 s.pages.Timeline(w, pages.TimelineParams{
161 LoggedInUser: user,
162 })
163 return
164}
165
166// requires auth
167func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) {
168 switch r.Method {
169 case http.MethodGet:
170 // list open registrations under this did
171
172 return
173 case http.MethodPost:
174 session, err := s.auth.Store.Get(r, appview.SessionName)
175 if err != nil || session.IsNew {
176 log.Println("unauthorized attempt to generate registration key")
177 http.Error(w, "Forbidden", http.StatusUnauthorized)
178 return
179 }
180
181 did := session.Values[appview.SessionDid].(string)
182
183 // check if domain is valid url, and strip extra bits down to just host
184 domain := r.FormValue("domain")
185 if domain == "" {
186 http.Error(w, "Invalid form", http.StatusBadRequest)
187 return
188 }
189
190 key, err := s.db.GenerateRegistrationKey(domain, did)
191
192 if err != nil {
193 log.Println(err)
194 http.Error(w, "unable to register this domain", http.StatusNotAcceptable)
195 return
196 }
197
198 w.Write([]byte(key))
199 }
200}
201
202func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
203 user := chi.URLParam(r, "user")
204 user = strings.TrimPrefix(user, "@")
205
206 if user == "" {
207 w.WriteHeader(http.StatusBadRequest)
208 return
209 }
210
211 id, err := s.resolver.ResolveIdent(r.Context(), user)
212 if err != nil {
213 w.WriteHeader(http.StatusInternalServerError)
214 return
215 }
216
217 pubKeys, err := s.db.GetPublicKeys(id.DID.String())
218 if err != nil {
219 w.WriteHeader(http.StatusNotFound)
220 return
221 }
222
223 if len(pubKeys) == 0 {
224 w.WriteHeader(http.StatusNotFound)
225 return
226 }
227
228 for _, k := range pubKeys {
229 key := strings.TrimRight(k.Key, "\n")
230 w.Write([]byte(fmt.Sprintln(key)))
231 }
232}
233
234// create a signed request and check if a node responds to that
235func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) {
236 user := s.auth.GetUser(r)
237
238 domain := chi.URLParam(r, "domain")
239 if domain == "" {
240 http.Error(w, "malformed url", http.StatusBadRequest)
241 return
242 }
243 log.Println("checking ", domain)
244
245 secret, err := s.db.GetRegistrationKey(domain)
246 if err != nil {
247 log.Printf("no key found for domain %s: %s\n", domain, err)
248 return
249 }
250
251 client, err := NewSignedClient(domain, secret)
252 if err != nil {
253 log.Println("failed to create client to ", domain)
254 }
255
256 resp, err := client.Init(user.Did)
257 if err != nil {
258 w.Write([]byte("no dice"))
259 log.Println("domain was unreachable after 5 seconds")
260 return
261 }
262
263 if resp.StatusCode == http.StatusConflict {
264 log.Println("status conflict", resp.StatusCode)
265 w.Write([]byte("already registered, sorry!"))
266 return
267 }
268
269 if resp.StatusCode != http.StatusNoContent {
270 log.Println("status nok", resp.StatusCode)
271 w.Write([]byte("no dice"))
272 return
273 }
274
275 // verify response mac
276 signature := resp.Header.Get("X-Signature")
277 signatureBytes, err := hex.DecodeString(signature)
278 if err != nil {
279 return
280 }
281
282 expectedMac := hmac.New(sha256.New, []byte(secret))
283 expectedMac.Write([]byte("ok"))
284
285 if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
286 log.Printf("response body signature mismatch: %x\n", signatureBytes)
287 return
288 }
289
290 // mark as registered
291 err = s.db.Register(domain)
292 if err != nil {
293 log.Println("failed to register domain", err)
294 http.Error(w, err.Error(), http.StatusInternalServerError)
295 return
296 }
297
298 // set permissions for this did as owner
299 reg, err := s.db.RegistrationByDomain(domain)
300 if err != nil {
301 log.Println("failed to register domain", err)
302 http.Error(w, err.Error(), http.StatusInternalServerError)
303 return
304 }
305
306 // add basic acls for this domain
307 err = s.enforcer.AddDomain(domain)
308 if err != nil {
309 log.Println("failed to setup owner of domain", err)
310 http.Error(w, err.Error(), http.StatusInternalServerError)
311 return
312 }
313
314 // add this did as owner of this domain
315 err = s.enforcer.AddOwner(domain, reg.ByDid)
316 if err != nil {
317 log.Println("failed to setup owner of domain", err)
318 http.Error(w, err.Error(), http.StatusInternalServerError)
319 return
320 }
321
322 w.Write([]byte("check success"))
323}
324
325func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) {
326 domain := chi.URLParam(r, "domain")
327 if domain == "" {
328 http.Error(w, "malformed url", http.StatusBadRequest)
329 return
330 }
331
332 user := s.auth.GetUser(r)
333 reg, err := s.db.RegistrationByDomain(domain)
334 if err != nil {
335 w.Write([]byte("failed to pull up registration info"))
336 return
337 }
338
339 var members []string
340 if reg.Registered != nil {
341 members, err = s.enforcer.GetUserByRole("server:member", domain)
342 if err != nil {
343 w.Write([]byte("failed to fetch member list"))
344 return
345 }
346 }
347
348 ok, err := s.enforcer.IsServerOwner(user.Did, domain)
349 isOwner := err == nil && ok
350
351 p := pages.KnotParams{
352 LoggedInUser: user,
353 Registration: reg,
354 Members: members,
355 IsOwner: isOwner,
356 }
357
358 s.pages.Knot(w, p)
359}
360
361// get knots registered by this user
362func (s *State) Knots(w http.ResponseWriter, r *http.Request) {
363 // for now, this is just pubkeys
364 user := s.auth.GetUser(r)
365 registrations, err := s.db.RegistrationsByDid(user.Did)
366 if err != nil {
367 log.Println(err)
368 }
369
370 s.pages.Knots(w, pages.KnotsParams{
371 LoggedInUser: user,
372 Registrations: registrations,
373 })
374}
375
376// list members of domain, requires auth and requires owner status
377func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) {
378 domain := chi.URLParam(r, "domain")
379 if domain == "" {
380 http.Error(w, "malformed url", http.StatusBadRequest)
381 return
382 }
383
384 // list all members for this domain
385 memberDids, err := s.enforcer.GetUserByRole("server:member", domain)
386 if err != nil {
387 w.Write([]byte("failed to fetch member list"))
388 return
389 }
390
391 w.Write([]byte(strings.Join(memberDids, "\n")))
392 return
393}
394
395// add member to domain, requires auth and requires invite access
396func (s *State) AddMember(w http.ResponseWriter, r *http.Request) {
397 domain := chi.URLParam(r, "domain")
398 if domain == "" {
399 http.Error(w, "malformed url", http.StatusBadRequest)
400 return
401 }
402
403 memberDid := r.FormValue("member")
404 if memberDid == "" {
405 http.Error(w, "malformed form", http.StatusBadRequest)
406 return
407 }
408
409 memberIdent, err := s.resolver.ResolveIdent(r.Context(), memberDid)
410 if err != nil {
411 w.Write([]byte("failed to resolve member did to a handle"))
412 return
413 }
414 log.Printf("adding %s to %s\n", memberIdent.Handle.String(), domain)
415
416 // announce this relation into the firehose, store into owners' pds
417 client, _ := s.auth.AuthorizedClient(r)
418 currentUser := s.auth.GetUser(r)
419 addedAt := time.Now().Format(time.RFC3339)
420 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
421 Collection: tangled.KnotMemberNSID,
422 Repo: currentUser.Did,
423 Rkey: s.TID(),
424 Record: &lexutil.LexiconTypeDecoder{
425 Val: &tangled.KnotMember{
426 Member: memberIdent.DID.String(),
427 Domain: domain,
428 AddedAt: &addedAt,
429 }},
430 })
431
432 // invalid record
433 if err != nil {
434 log.Printf("failed to create record: %s", err)
435 return
436 }
437 log.Println("created atproto record: ", resp.Uri)
438
439 secret, err := s.db.GetRegistrationKey(domain)
440 if err != nil {
441 log.Printf("no key found for domain %s: %s\n", domain, err)
442 return
443 }
444
445 ksClient, err := NewSignedClient(domain, secret)
446 if err != nil {
447 log.Println("failed to create client to ", domain)
448 return
449 }
450
451 ksResp, err := ksClient.AddMember(memberIdent.DID.String())
452 if err != nil {
453 log.Printf("failed to make request to %s: %s", domain, err)
454 return
455 }
456
457 if ksResp.StatusCode != http.StatusNoContent {
458 w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err)))
459 return
460 }
461
462 err = s.enforcer.AddMember(domain, memberIdent.DID.String())
463 if err != nil {
464 w.Write([]byte(fmt.Sprint("failed to add member: ", err)))
465 return
466 }
467
468 w.Write([]byte(fmt.Sprint("added member: ", memberIdent.Handle.String())))
469}
470
471func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) {
472}
473
474func (s *State) AddRepo(w http.ResponseWriter, r *http.Request) {
475 switch r.Method {
476 case http.MethodGet:
477 user := s.auth.GetUser(r)
478 knots, err := s.enforcer.GetDomainsForUser(user.Did)
479
480 if err != nil {
481 s.pages.Notice(w, "repo", "Invalid user account.")
482 return
483 }
484
485 s.pages.NewRepo(w, pages.NewRepoParams{
486 LoggedInUser: user,
487 Knots: knots,
488 })
489 case http.MethodPost:
490 user := s.auth.GetUser(r)
491
492 domain := r.FormValue("domain")
493 if domain == "" {
494 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
495 return
496 }
497
498 repoName := r.FormValue("name")
499 if repoName == "" {
500 s.pages.Notice(w, "repo", "Invalid repo name.")
501 return
502 }
503
504 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
505 if err != nil || !ok {
506 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
507 return
508 }
509
510 secret, err := s.db.GetRegistrationKey(domain)
511 if err != nil {
512 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
513 return
514 }
515
516 client, err := NewSignedClient(domain, secret)
517 if err != nil {
518 s.pages.Notice(w, "repo", "Failed to connect to knot server.")
519 return
520 }
521
522 resp, err := client.NewRepo(user.Did, repoName)
523 if err != nil {
524 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
525 return
526 }
527
528 switch resp.StatusCode {
529 case http.StatusConflict:
530 s.pages.Notice(w, "repo", "A repository with that name already exists.")
531 return
532 case http.StatusInternalServerError:
533 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
534 case http.StatusNoContent:
535 // continue
536 }
537
538 rkey := s.TID()
539 repo := &db.Repo{
540 Did: user.Did,
541 Name: repoName,
542 Knot: domain,
543 Rkey: rkey,
544 }
545
546 xrpcClient, _ := s.auth.AuthorizedClient(r)
547
548 addedAt := time.Now().Format(time.RFC3339)
549 atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
550 Collection: tangled.RepoNSID,
551 Repo: user.Did,
552 Rkey: rkey,
553 Record: &lexutil.LexiconTypeDecoder{
554 Val: &tangled.Repo{
555 Knot: repo.Knot,
556 Name: repoName,
557 AddedAt: &addedAt,
558 Owner: user.Did,
559 }},
560 })
561 if err != nil {
562 log.Printf("failed to create record: %s", err)
563 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
564 return
565 }
566 log.Println("created repo record: ", atresp.Uri)
567
568 err = s.db.AddRepo(repo)
569 if err != nil {
570 log.Println(err)
571 s.pages.Notice(w, "repo", "Failed to save repository information.")
572 return
573 }
574
575 // acls
576 p, _ := securejoin.SecureJoin(user.Did, repoName)
577 err = s.enforcer.AddRepo(user.Did, domain, p)
578 if err != nil {
579 log.Println(err)
580 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
581 return
582 }
583
584 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
585 return
586 }
587}
588
589func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
590 didOrHandle := chi.URLParam(r, "user")
591 if didOrHandle == "" {
592 http.Error(w, "Bad request", http.StatusBadRequest)
593 return
594 }
595
596 ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle)
597 if err != nil {
598 log.Printf("resolving identity: %s", err)
599 w.WriteHeader(http.StatusNotFound)
600 return
601 }
602
603 repos, err := s.db.GetAllReposByDid(ident.DID.String())
604 if err != nil {
605 log.Printf("getting repos for %s: %s", ident.DID.String(), err)
606 }
607
608 collaboratingRepos, err := s.db.CollaboratingIn(ident.DID.String())
609 if err != nil {
610 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
611 }
612
613 s.pages.ProfilePage(w, pages.ProfilePageParams{
614 LoggedInUser: s.auth.GetUser(r),
615 UserDid: ident.DID.String(),
616 UserHandle: ident.Handle.String(),
617 Repos: repos,
618 CollaboratingRepos: collaboratingRepos,
619 })
620}
621
622func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
623 currentUser := s.auth.GetUser(r)
624
625 subject := r.URL.Query().Get("subject")
626 if subject == "" {
627 log.Println("invalid form")
628 return
629 }
630
631 subjectIdent, err := s.resolver.ResolveIdent(r.Context(), subject)
632 if err != nil {
633 log.Println("failed to follow, invalid did")
634 }
635
636 if currentUser.Did == subjectIdent.DID.String() {
637 log.Println("cant follow or unfollow yourself")
638 return
639 }
640
641 client, _ := s.auth.AuthorizedClient(r)
642
643 switch r.Method {
644 case http.MethodPost:
645 createdAt := time.Now().Format(time.RFC3339)
646 rkey := s.TID()
647 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
648 Collection: tangled.GraphFollowNSID,
649 Repo: currentUser.Did,
650 Rkey: rkey,
651 Record: &lexutil.LexiconTypeDecoder{
652 Val: &tangled.GraphFollow{
653 Subject: subjectIdent.DID.String(),
654 CreatedAt: createdAt,
655 }},
656 })
657 if err != nil {
658 log.Println("failed to create atproto record", err)
659 return
660 }
661
662 err = s.db.AddFollow(currentUser.Did, subjectIdent.DID.String(), rkey)
663 if err != nil {
664 log.Println("failed to follow", err)
665 return
666 }
667
668 log.Println("created atproto record: ", resp.Uri)
669
670 return
671 case http.MethodDelete:
672 // find the record in the db
673
674 follow, err := s.db.GetFollow(currentUser.Did, subjectIdent.DID.String())
675 if err != nil {
676 log.Println("failed to get follow relationship")
677 return
678 }
679
680 resp, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
681 Collection: tangled.GraphFollowNSID,
682 Repo: currentUser.Did,
683 Rkey: follow.RKey,
684 })
685
686 log.Println(resp.Commit.Cid)
687
688 if err != nil {
689 log.Println("failed to unfollow")
690 return
691 }
692
693 err = s.db.DeleteFollow(currentUser.Did, subjectIdent.DID.String())
694 if err != nil {
695 log.Println("failed to delete follow from DB")
696 // this is not an issue, the firehose event might have already done this
697 }
698
699 w.WriteHeader(http.StatusNoContent)
700 return
701 }
702
703}
704
705func (s *State) Router() http.Handler {
706 router := chi.NewRouter()
707
708 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
709 pat := chi.URLParam(r, "*")
710 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
711 s.UserRouter().ServeHTTP(w, r)
712 } else {
713 s.StandardRouter().ServeHTTP(w, r)
714 }
715 })
716
717 return router
718}
719
720func (s *State) UserRouter() http.Handler {
721 r := chi.NewRouter()
722
723 // strip @ from user
724 r.Use(StripLeadingAt)
725
726 r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) {
727 r.Get("/", s.ProfilePage)
728 r.With(ResolveRepoKnot(s)).Route("/{repo}", func(r chi.Router) {
729 r.Get("/", s.RepoIndex)
730 r.Get("/log/{ref}", s.RepoLog)
731 r.Route("/tree/{ref}", func(r chi.Router) {
732 r.Get("/", s.RepoIndex)
733 r.Get("/*", s.RepoTree)
734 })
735 r.Get("/commit/{ref}", s.RepoCommit)
736 r.Get("/branches", s.RepoBranches)
737 r.Get("/tags", s.RepoTags)
738 r.Get("/blob/{ref}/*", s.RepoBlob)
739
740 // These routes get proxied to the knot
741 r.Get("/info/refs", s.InfoRefs)
742 r.Post("/git-upload-pack", s.UploadPack)
743
744 // settings routes, needs auth
745 r.Group(func(r chi.Router) {
746 r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) {
747 r.Get("/", s.RepoSettings)
748 r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator)
749 })
750 })
751 })
752 })
753
754 r.NotFound(func(w http.ResponseWriter, r *http.Request) {
755 s.pages.Error404(w)
756 })
757
758 return r
759}
760
761func (s *State) StandardRouter() http.Handler {
762 r := chi.NewRouter()
763
764 r.Handle("/static/*", s.pages.Static())
765
766 r.Get("/", s.Timeline)
767
768 r.Get("/logout", s.Logout)
769
770 r.Get("/login", s.Login)
771 r.Post("/login", s.Login)
772
773 r.Route("/knots", func(r chi.Router) {
774 r.Use(AuthMiddleware(s))
775 r.Get("/", s.Knots)
776 r.Post("/key", s.RegistrationKey)
777
778 r.Route("/{domain}", func(r chi.Router) {
779 r.Post("/init", s.InitKnotServer)
780 r.Get("/", s.KnotServerInfo)
781 r.Route("/member", func(r chi.Router) {
782 r.Use(RoleMiddleware(s, "server:owner"))
783 r.Get("/", s.ListMembers)
784 r.Put("/", s.AddMember)
785 r.Delete("/", s.RemoveMember)
786 })
787 })
788 })
789
790 r.Route("/repo", func(r chi.Router) {
791 r.Route("/new", func(r chi.Router) {
792 r.Get("/", s.AddRepo)
793 r.Post("/", s.AddRepo)
794 })
795 // r.Post("/import", s.ImportRepo)
796 })
797
798 r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) {
799 r.Post("/", s.Follow)
800 r.Delete("/", s.Follow)
801 })
802
803 r.Route("/settings", func(r chi.Router) {
804 r.Use(AuthMiddleware(s))
805 r.Get("/", s.Settings)
806 r.Put("/keys", s.SettingsKeys)
807 })
808
809 r.Get("/keys/{user}", s.Keys)
810
811 r.NotFound(func(w http.ResponseWriter, r *http.Request) {
812 s.pages.Error404(w)
813 })
814 return r
815}