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