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}