forked from tangled.org/core
Monorepo for Tangled

appview: middleware: factor out shared middleware

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.sh>

anirudh.fi e1ab0187 b8fa24f6

verified
Changed files
+266 -245
appview
+241 -2
appview/middleware/middleware.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 "log" 6 7 "net/http" 8 + "slices" 7 9 "strconv" 10 + "strings" 11 + "time" 8 12 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/go-chi/chi/v5" 15 + "tangled.sh/tangled.sh/core/appview" 16 + "tangled.sh/tangled.sh/core/appview/db" 9 17 "tangled.sh/tangled.sh/core/appview/oauth" 18 + "tangled.sh/tangled.sh/core/appview/pages" 10 19 "tangled.sh/tangled.sh/core/appview/pagination" 20 + "tangled.sh/tangled.sh/core/appview/reporesolver" 21 + "tangled.sh/tangled.sh/core/rbac" 11 22 ) 12 23 13 - type Middleware func(http.Handler) http.Handler 24 + type Middleware struct { 25 + oauth *oauth.OAuth 26 + db *db.DB 27 + enforcer rbac.Enforcer 28 + repoResolver *reporesolver.RepoResolver 29 + resolver *appview.Resolver 30 + pages *pages.Pages 31 + } 32 + 33 + func New(oauth *oauth.OAuth, db *db.DB, enforcer rbac.Enforcer, repoResolver *reporesolver.RepoResolver, resolver *appview.Resolver, pages *pages.Pages) Middleware { 34 + return Middleware{ 35 + oauth: oauth, 36 + db: db, 37 + enforcer: enforcer, 38 + repoResolver: repoResolver, 39 + resolver: resolver, 40 + pages: pages, 41 + } 42 + } 43 + 44 + type middlewareFunc func(http.Handler) http.Handler 14 45 15 - func AuthMiddleware(a *oauth.OAuth) Middleware { 46 + func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 16 47 return func(next http.Handler) http.Handler { 17 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 49 redirectFunc := func(w http.ResponseWriter, r *http.Request) { ··· 71 102 next.ServeHTTP(w, r.WithContext(ctx)) 72 103 }) 73 104 } 105 + 106 + func (mw Middleware) knotRoleMiddleware(group string) middlewareFunc { 107 + return func(next http.Handler) http.Handler { 108 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 109 + // requires auth also 110 + actor := mw.oauth.GetUser(r) 111 + if actor == nil { 112 + // we need a logged in user 113 + log.Printf("not logged in, redirecting") 114 + http.Error(w, "Forbiden", http.StatusUnauthorized) 115 + return 116 + } 117 + domain := chi.URLParam(r, "domain") 118 + if domain == "" { 119 + http.Error(w, "malformed url", http.StatusBadRequest) 120 + return 121 + } 122 + 123 + ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Did, group, domain) 124 + if err != nil || !ok { 125 + // we need a logged in user 126 + log.Printf("%s does not have perms of a %s in domain %s", actor.Did, group, domain) 127 + http.Error(w, "Forbiden", http.StatusUnauthorized) 128 + return 129 + } 130 + 131 + next.ServeHTTP(w, r) 132 + }) 133 + } 134 + } 135 + 136 + func (mw Middleware) KnotOwner() middlewareFunc { 137 + return mw.knotRoleMiddleware("server:owner") 138 + } 139 + 140 + func (mw Middleware) RepoPermissionMiddleware(requiredPerm string) middlewareFunc { 141 + return func(next http.Handler) http.Handler { 142 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 143 + // requires auth also 144 + actor := mw.oauth.GetUser(r) 145 + if actor == nil { 146 + // we need a logged in user 147 + log.Printf("not logged in, redirecting") 148 + http.Error(w, "Forbiden", http.StatusUnauthorized) 149 + return 150 + } 151 + f, err := mw.repoResolver.Resolve(r) 152 + if err != nil { 153 + http.Error(w, "malformed url", http.StatusBadRequest) 154 + return 155 + } 156 + 157 + ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 158 + if err != nil || !ok { 159 + // we need a logged in user 160 + log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) 161 + http.Error(w, "Forbiden", http.StatusUnauthorized) 162 + return 163 + } 164 + 165 + next.ServeHTTP(w, r) 166 + }) 167 + } 168 + } 169 + 170 + func StripLeadingAt(next http.Handler) http.Handler { 171 + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 172 + path := req.URL.EscapedPath() 173 + if strings.HasPrefix(path, "/@") { 174 + req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") 175 + } 176 + next.ServeHTTP(w, req) 177 + }) 178 + } 179 + 180 + func (mw Middleware) ResolveIdent() middlewareFunc { 181 + excluded := []string{"favicon.ico"} 182 + 183 + return func(next http.Handler) http.Handler { 184 + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 185 + didOrHandle := chi.URLParam(req, "user") 186 + if slices.Contains(excluded, didOrHandle) { 187 + next.ServeHTTP(w, req) 188 + return 189 + } 190 + 191 + id, err := mw.resolver.ResolveIdent(req.Context(), didOrHandle) 192 + if err != nil { 193 + // invalid did or handle 194 + log.Println("failed to resolve did/handle:", err) 195 + w.WriteHeader(http.StatusNotFound) 196 + return 197 + } 198 + 199 + ctx := context.WithValue(req.Context(), "resolvedId", *id) 200 + 201 + next.ServeHTTP(w, req.WithContext(ctx)) 202 + }) 203 + } 204 + } 205 + 206 + func (mw Middleware) ResolveRepo() middlewareFunc { 207 + return func(next http.Handler) http.Handler { 208 + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 209 + repoName := chi.URLParam(req, "repo") 210 + id, ok := req.Context().Value("resolvedId").(identity.Identity) 211 + if !ok { 212 + log.Println("malformed middleware") 213 + w.WriteHeader(http.StatusInternalServerError) 214 + return 215 + } 216 + 217 + repo, err := db.GetRepo(mw.db, id.DID.String(), repoName) 218 + if err != nil { 219 + // invalid did or handle 220 + log.Println("failed to resolve repo") 221 + mw.pages.Error404(w) 222 + return 223 + } 224 + 225 + ctx := context.WithValue(req.Context(), "knot", repo.Knot) 226 + ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 227 + ctx = context.WithValue(ctx, "repoDescription", repo.Description) 228 + ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 229 + next.ServeHTTP(w, req.WithContext(ctx)) 230 + }) 231 + } 232 + } 233 + 234 + // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 235 + func (mw Middleware) ResolvePull() middlewareFunc { 236 + return func(next http.Handler) http.Handler { 237 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 238 + f, err := mw.repoResolver.Resolve(r) 239 + if err != nil { 240 + log.Println("failed to fully resolve repo", err) 241 + http.Error(w, "invalid repo url", http.StatusNotFound) 242 + return 243 + } 244 + 245 + prId := chi.URLParam(r, "pull") 246 + prIdInt, err := strconv.Atoi(prId) 247 + if err != nil { 248 + http.Error(w, "bad pr id", http.StatusBadRequest) 249 + log.Println("failed to parse pr id", err) 250 + return 251 + } 252 + 253 + pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 254 + if err != nil { 255 + log.Println("failed to get pull and comments", err) 256 + return 257 + } 258 + 259 + ctx := context.WithValue(r.Context(), "pull", pr) 260 + 261 + if pr.IsStacked() { 262 + stack, err := db.GetStack(mw.db, pr.StackId) 263 + if err != nil { 264 + log.Println("failed to get stack", err) 265 + return 266 + } 267 + abandonedPulls, err := db.GetAbandonedPulls(mw.db, pr.StackId) 268 + if err != nil { 269 + log.Println("failed to get abandoned pulls", err) 270 + return 271 + } 272 + 273 + ctx = context.WithValue(ctx, "stack", stack) 274 + ctx = context.WithValue(ctx, "abandonedPulls", abandonedPulls) 275 + } 276 + 277 + next.ServeHTTP(w, r.WithContext(ctx)) 278 + }) 279 + } 280 + } 281 + 282 + // this should serve the go-import meta tag even if the path is technically 283 + // a 404 like tangled.sh/oppi.li/go-git/v5 284 + func (mw Middleware) GoImport() middlewareFunc { 285 + return func(next http.Handler) http.Handler { 286 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 287 + f, err := mw.repoResolver.Resolve(r) 288 + if err != nil { 289 + log.Println("failed to fully resolve repo", err) 290 + http.Error(w, "invalid repo url", http.StatusNotFound) 291 + return 292 + } 293 + 294 + fullName := f.OwnerHandle() + "/" + f.RepoName 295 + 296 + if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 297 + if r.URL.Query().Get("go-get") == "1" { 298 + html := fmt.Sprintf( 299 + `<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, 300 + fullName, 301 + fullName, 302 + ) 303 + w.Header().Set("Content-Type", "text/html") 304 + w.Write([]byte(html)) 305 + return 306 + } 307 + } 308 + 309 + next.ServeHTTP(w, r) 310 + }) 311 + } 312 + }
-226
appview/state/middleware.go
··· 1 - package state 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "log" 7 - "net/http" 8 - "strconv" 9 - "strings" 10 - "time" 11 - 12 - "slices" 13 - 14 - "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/go-chi/chi/v5" 16 - "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/middleware" 18 - ) 19 - 20 - func knotRoleMiddleware(s *State, group string) middleware.Middleware { 21 - return func(next http.Handler) http.Handler { 22 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 - // requires auth also 24 - actor := s.oauth.GetUser(r) 25 - if actor == nil { 26 - // we need a logged in user 27 - log.Printf("not logged in, redirecting") 28 - http.Error(w, "Forbiden", http.StatusUnauthorized) 29 - return 30 - } 31 - domain := chi.URLParam(r, "domain") 32 - if domain == "" { 33 - http.Error(w, "malformed url", http.StatusBadRequest) 34 - return 35 - } 36 - 37 - ok, err := s.enforcer.E.HasGroupingPolicy(actor.Did, group, domain) 38 - if err != nil || !ok { 39 - // we need a logged in user 40 - log.Printf("%s does not have perms of a %s in domain %s", actor.Did, group, domain) 41 - http.Error(w, "Forbiden", http.StatusUnauthorized) 42 - return 43 - } 44 - 45 - next.ServeHTTP(w, r) 46 - }) 47 - } 48 - } 49 - 50 - func KnotOwner(s *State) middleware.Middleware { 51 - return knotRoleMiddleware(s, "server:owner") 52 - } 53 - 54 - func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware { 55 - return func(next http.Handler) http.Handler { 56 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 - // requires auth also 58 - actor := s.oauth.GetUser(r) 59 - if actor == nil { 60 - // we need a logged in user 61 - log.Printf("not logged in, redirecting") 62 - http.Error(w, "Forbiden", http.StatusUnauthorized) 63 - return 64 - } 65 - f, err := s.repoResolver.Resolve(r) 66 - if err != nil { 67 - http.Error(w, "malformed url", http.StatusBadRequest) 68 - return 69 - } 70 - 71 - ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 72 - if err != nil || !ok { 73 - // we need a logged in user 74 - log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) 75 - http.Error(w, "Forbiden", http.StatusUnauthorized) 76 - return 77 - } 78 - 79 - next.ServeHTTP(w, r) 80 - }) 81 - } 82 - } 83 - 84 - func StripLeadingAt(next http.Handler) http.Handler { 85 - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 86 - path := req.URL.EscapedPath() 87 - if strings.HasPrefix(path, "/@") { 88 - req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") 89 - } 90 - next.ServeHTTP(w, req) 91 - }) 92 - } 93 - 94 - func ResolveIdent(s *State) middleware.Middleware { 95 - excluded := []string{"favicon.ico"} 96 - 97 - return func(next http.Handler) http.Handler { 98 - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 99 - didOrHandle := chi.URLParam(req, "user") 100 - if slices.Contains(excluded, didOrHandle) { 101 - next.ServeHTTP(w, req) 102 - return 103 - } 104 - 105 - id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle) 106 - if err != nil { 107 - // invalid did or handle 108 - log.Println("failed to resolve did/handle:", err) 109 - w.WriteHeader(http.StatusNotFound) 110 - return 111 - } 112 - 113 - ctx := context.WithValue(req.Context(), "resolvedId", *id) 114 - 115 - next.ServeHTTP(w, req.WithContext(ctx)) 116 - }) 117 - } 118 - } 119 - 120 - func ResolveRepo(s *State) middleware.Middleware { 121 - return func(next http.Handler) http.Handler { 122 - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 123 - repoName := chi.URLParam(req, "repo") 124 - id, ok := req.Context().Value("resolvedId").(identity.Identity) 125 - if !ok { 126 - log.Println("malformed middleware") 127 - w.WriteHeader(http.StatusInternalServerError) 128 - return 129 - } 130 - 131 - repo, err := db.GetRepo(s.db, id.DID.String(), repoName) 132 - if err != nil { 133 - // invalid did or handle 134 - log.Println("failed to resolve repo") 135 - s.pages.Error404(w) 136 - return 137 - } 138 - 139 - ctx := context.WithValue(req.Context(), "knot", repo.Knot) 140 - ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 141 - ctx = context.WithValue(ctx, "repoDescription", repo.Description) 142 - ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 143 - next.ServeHTTP(w, req.WithContext(ctx)) 144 - }) 145 - } 146 - } 147 - 148 - // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 149 - func ResolvePull(s *State) middleware.Middleware { 150 - return func(next http.Handler) http.Handler { 151 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 152 - f, err := s.repoResolver.Resolve(r) 153 - if err != nil { 154 - log.Println("failed to fully resolve repo", err) 155 - http.Error(w, "invalid repo url", http.StatusNotFound) 156 - return 157 - } 158 - 159 - prId := chi.URLParam(r, "pull") 160 - prIdInt, err := strconv.Atoi(prId) 161 - if err != nil { 162 - http.Error(w, "bad pr id", http.StatusBadRequest) 163 - log.Println("failed to parse pr id", err) 164 - return 165 - } 166 - 167 - pr, err := db.GetPull(s.db, f.RepoAt, prIdInt) 168 - if err != nil { 169 - log.Println("failed to get pull and comments", err) 170 - return 171 - } 172 - 173 - ctx := context.WithValue(r.Context(), "pull", pr) 174 - 175 - if pr.IsStacked() { 176 - stack, err := db.GetStack(s.db, pr.StackId) 177 - if err != nil { 178 - log.Println("failed to get stack", err) 179 - return 180 - } 181 - abandonedPulls, err := db.GetAbandonedPulls(s.db, pr.StackId) 182 - if err != nil { 183 - log.Println("failed to get abandoned pulls", err) 184 - return 185 - } 186 - 187 - ctx = context.WithValue(ctx, "stack", stack) 188 - ctx = context.WithValue(ctx, "abandonedPulls", abandonedPulls) 189 - } 190 - 191 - next.ServeHTTP(w, r.WithContext(ctx)) 192 - }) 193 - } 194 - } 195 - 196 - // this should serve the go-import meta tag even if the path is technically 197 - // a 404 like tangled.sh/oppi.li/go-git/v5 198 - func GoImport(s *State) middleware.Middleware { 199 - return func(next http.Handler) http.Handler { 200 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 201 - f, err := s.repoResolver.Resolve(r) 202 - if err != nil { 203 - log.Println("failed to fully resolve repo", err) 204 - http.Error(w, "invalid repo url", http.StatusNotFound) 205 - return 206 - } 207 - 208 - fullName := f.OwnerHandle() + "/" + f.RepoName 209 - 210 - if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 211 - if r.URL.Query().Get("go-get") == "1" { 212 - html := fmt.Sprintf( 213 - `<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, 214 - fullName, 215 - fullName, 216 - ) 217 - w.Header().Set("Content-Type", "text/html") 218 - w.Write([]byte(html)) 219 - return 220 - } 221 - } 222 - 223 - next.ServeHTTP(w, r) 224 - }) 225 - } 226 - }
+25 -17
appview/state/router.go
··· 14 14 15 15 func (s *State) Router() http.Handler { 16 16 router := chi.NewRouter() 17 + middleware := middleware.New( 18 + s.oauth, 19 + s.db, 20 + s.enforcer, 21 + s.repoResolver, 22 + s.resolver, 23 + s.pages, 24 + ) 17 25 18 26 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 19 27 pat := chi.URLParam(r, "*") 20 28 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 21 - s.UserRouter().ServeHTTP(w, r) 29 + s.UserRouter(&middleware).ServeHTTP(w, r) 22 30 } else { 23 31 // Check if the first path element is a valid handle without '@' or a flattened DID 24 32 pathParts := strings.SplitN(pat, "/", 2) ··· 41 49 return 42 50 } 43 51 } 44 - s.StandardRouter().ServeHTTP(w, r) 52 + s.StandardRouter(&middleware).ServeHTTP(w, r) 45 53 } 46 54 }) 47 55 48 56 return router 49 57 } 50 58 51 - func (s *State) UserRouter() http.Handler { 59 + func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 52 60 r := chi.NewRouter() 53 61 54 62 // strip @ from user 55 - r.Use(StripLeadingAt) 63 + r.Use(middleware.StripLeadingAt) 56 64 57 - r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) { 65 + r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 58 66 r.Get("/", s.Profile) 59 67 60 - r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) { 61 - r.Use(GoImport(s)) 68 + r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 69 + r.Use(mw.GoImport()) 62 70 63 71 r.Get("/", s.RepoIndex) 64 72 r.Get("/commits/{ref}", s.RepoLog) ··· 80 88 // additionally: only the uploader can truly delete an artifact 81 89 // (record+blob will live on their pds) 82 90 r.Group(func(r chi.Router) { 83 - r.With(RepoPermissionMiddleware(s, "repo:push")) 91 + r.With(mw.RepoPermissionMiddleware("repo:push")) 84 92 r.Post("/upload", s.AttachArtifact) 85 93 r.Delete("/{file}", s.DeleteArtifact) 86 94 }) ··· 113 121 r.Use(middleware.AuthMiddleware(s.oauth)) 114 122 r.Get("/", s.ForkRepo) 115 123 r.Post("/", s.ForkRepo) 116 - r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/sync", func(r chi.Router) { 124 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/sync", func(r chi.Router) { 117 125 r.Post("/", s.SyncRepoFork) 118 126 }) 119 127 }) ··· 143 151 }) 144 152 145 153 r.Route("/{pull}", func(r chi.Router) { 146 - r.Use(ResolvePull(s)) 154 + r.Use(mw.ResolvePull()) 147 155 r.Get("/", s.RepoSinglePull) 148 156 149 157 r.Route("/round/{round}", func(r chi.Router) { ··· 170 178 r.Post("/reopen", s.ReopenPull) 171 179 // collaborators only 172 180 r.Group(func(r chi.Router) { 173 - r.Use(RepoPermissionMiddleware(s, "repo:push")) 181 + r.Use(mw.RepoPermissionMiddleware("repo:push")) 174 182 r.Post("/merge", s.MergePull) 175 183 // maybe lock, etc. 176 184 }) ··· 187 195 r.Group(func(r chi.Router) { 188 196 r.Use(middleware.AuthMiddleware(s.oauth)) 189 197 // repo description can only be edited by owner 190 - r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) { 198 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) { 191 199 r.Put("/", s.RepoDescription) 192 200 r.Get("/", s.RepoDescription) 193 201 r.Get("/edit", s.RepoDescriptionEdit) 194 202 }) 195 - r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) { 203 + r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 196 204 r.Get("/", s.RepoSettings) 197 - r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator) 198 - r.With(RepoPermissionMiddleware(s, "repo:delete")).Delete("/delete", s.DeleteRepo) 205 + r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", s.AddCollaborator) 206 + r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", s.DeleteRepo) 199 207 r.Put("/branches/default", s.SetDefaultBranch) 200 208 }) 201 209 }) ··· 209 217 return r 210 218 } 211 219 212 - func (s *State) StandardRouter() http.Handler { 220 + func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { 213 221 r := chi.NewRouter() 214 222 215 223 r.Handle("/static/*", s.pages.Static()) ··· 227 235 r.Post("/init", s.InitKnotServer) 228 236 r.Get("/", s.KnotServerInfo) 229 237 r.Route("/member", func(r chi.Router) { 230 - r.Use(KnotOwner(s)) 238 + r.Use(mw.KnotOwner()) 231 239 r.Get("/", s.ListMembers) 232 240 r.Put("/", s.AddMember) 233 241 r.Delete("/", s.RemoveMember)