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