diff --git a/appview/middleware/middleware.go b/appview/middleware/middleware.go index 60d90b51..a0df824c 100644 --- a/appview/middleware/middleware.go +++ b/appview/middleware/middleware.go @@ -2,17 +2,48 @@ package middleware import ( "context" + "fmt" "log" "net/http" + "slices" "strconv" + "strings" + "time" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/go-chi/chi/v5" + "tangled.sh/tangled.sh/core/appview" + "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/oauth" + "tangled.sh/tangled.sh/core/appview/pages" "tangled.sh/tangled.sh/core/appview/pagination" + "tangled.sh/tangled.sh/core/appview/reporesolver" + "tangled.sh/tangled.sh/core/rbac" ) -type Middleware func(http.Handler) http.Handler +type Middleware struct { + oauth *oauth.OAuth + db *db.DB + enforcer rbac.Enforcer + repoResolver *reporesolver.RepoResolver + resolver *appview.Resolver + pages *pages.Pages +} + +func New(oauth *oauth.OAuth, db *db.DB, enforcer rbac.Enforcer, repoResolver *reporesolver.RepoResolver, resolver *appview.Resolver, pages *pages.Pages) Middleware { + return Middleware{ + oauth: oauth, + db: db, + enforcer: enforcer, + repoResolver: repoResolver, + resolver: resolver, + pages: pages, + } +} -func AuthMiddleware(a *oauth.OAuth) Middleware { +type middlewareFunc func(http.Handler) http.Handler + +func AuthMiddleware(a *oauth.OAuth) middlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { redirectFunc := func(w http.ResponseWriter, r *http.Request) { @@ -71,3 +102,211 @@ func Paginate(next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(ctx)) }) } + +func (mw Middleware) knotRoleMiddleware(group string) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // requires auth also + actor := mw.oauth.GetUser(r) + if actor == nil { + // we need a logged in user + log.Printf("not logged in, redirecting") + http.Error(w, "Forbiden", http.StatusUnauthorized) + return + } + domain := chi.URLParam(r, "domain") + if domain == "" { + http.Error(w, "malformed url", http.StatusBadRequest) + return + } + + ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Did, group, domain) + if err != nil || !ok { + // we need a logged in user + log.Printf("%s does not have perms of a %s in domain %s", actor.Did, group, domain) + http.Error(w, "Forbiden", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func (mw Middleware) KnotOwner() middlewareFunc { + return mw.knotRoleMiddleware("server:owner") +} + +func (mw Middleware) RepoPermissionMiddleware(requiredPerm string) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // requires auth also + actor := mw.oauth.GetUser(r) + if actor == nil { + // we need a logged in user + log.Printf("not logged in, redirecting") + http.Error(w, "Forbiden", http.StatusUnauthorized) + return + } + f, err := mw.repoResolver.Resolve(r) + if err != nil { + http.Error(w, "malformed url", http.StatusBadRequest) + return + } + + ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) + if err != nil || !ok { + // we need a logged in user + log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) + http.Error(w, "Forbiden", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func StripLeadingAt(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + path := req.URL.EscapedPath() + if strings.HasPrefix(path, "/@") { + req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") + } + next.ServeHTTP(w, req) + }) +} + +func (mw Middleware) ResolveIdent() middlewareFunc { + excluded := []string{"favicon.ico"} + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + didOrHandle := chi.URLParam(req, "user") + if slices.Contains(excluded, didOrHandle) { + next.ServeHTTP(w, req) + return + } + + id, err := mw.resolver.ResolveIdent(req.Context(), didOrHandle) + if err != nil { + // invalid did or handle + log.Println("failed to resolve did/handle:", err) + w.WriteHeader(http.StatusNotFound) + return + } + + ctx := context.WithValue(req.Context(), "resolvedId", *id) + + next.ServeHTTP(w, req.WithContext(ctx)) + }) + } +} + +func (mw Middleware) ResolveRepo() middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + repoName := chi.URLParam(req, "repo") + id, ok := req.Context().Value("resolvedId").(identity.Identity) + if !ok { + log.Println("malformed middleware") + w.WriteHeader(http.StatusInternalServerError) + return + } + + repo, err := db.GetRepo(mw.db, id.DID.String(), repoName) + if err != nil { + // invalid did or handle + log.Println("failed to resolve repo") + mw.pages.Error404(w) + return + } + + ctx := context.WithValue(req.Context(), "knot", repo.Knot) + ctx = context.WithValue(ctx, "repoAt", repo.AtUri) + ctx = context.WithValue(ctx, "repoDescription", repo.Description) + ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) + next.ServeHTTP(w, req.WithContext(ctx)) + }) + } +} + +// middleware that is tacked on top of /{user}/{repo}/pulls/{pull} +func (mw Middleware) ResolvePull() middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f, err := mw.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to fully resolve repo", err) + http.Error(w, "invalid repo url", http.StatusNotFound) + return + } + + prId := chi.URLParam(r, "pull") + prIdInt, err := strconv.Atoi(prId) + if err != nil { + http.Error(w, "bad pr id", http.StatusBadRequest) + log.Println("failed to parse pr id", err) + return + } + + pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) + if err != nil { + log.Println("failed to get pull and comments", err) + return + } + + ctx := context.WithValue(r.Context(), "pull", pr) + + if pr.IsStacked() { + stack, err := db.GetStack(mw.db, pr.StackId) + if err != nil { + log.Println("failed to get stack", err) + return + } + abandonedPulls, err := db.GetAbandonedPulls(mw.db, pr.StackId) + if err != nil { + log.Println("failed to get abandoned pulls", err) + return + } + + ctx = context.WithValue(ctx, "stack", stack) + ctx = context.WithValue(ctx, "abandonedPulls", abandonedPulls) + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// this should serve the go-import meta tag even if the path is technically +// a 404 like tangled.sh/oppi.li/go-git/v5 +func (mw Middleware) GoImport() middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f, err := mw.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to fully resolve repo", err) + http.Error(w, "invalid repo url", http.StatusNotFound) + return + } + + fullName := f.OwnerHandle() + "/" + f.RepoName + + if r.Header.Get("User-Agent") == "Go-http-client/1.1" { + if r.URL.Query().Get("go-get") == "1" { + html := fmt.Sprintf( + ``, + fullName, + fullName, + ) + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(html)) + return + } + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/appview/state/middleware.go b/appview/state/middleware.go deleted file mode 100644 index 2a795676..00000000 --- a/appview/state/middleware.go +++ /dev/null @@ -1,226 +0,0 @@ -package state - -import ( - "context" - "fmt" - "log" - "net/http" - "strconv" - "strings" - "time" - - "slices" - - "github.com/bluesky-social/indigo/atproto/identity" - "github.com/go-chi/chi/v5" - "tangled.sh/tangled.sh/core/appview/db" - "tangled.sh/tangled.sh/core/appview/middleware" -) - -func knotRoleMiddleware(s *State, group string) middleware.Middleware { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // requires auth also - actor := s.oauth.GetUser(r) - if actor == nil { - // we need a logged in user - log.Printf("not logged in, redirecting") - http.Error(w, "Forbiden", http.StatusUnauthorized) - return - } - domain := chi.URLParam(r, "domain") - if domain == "" { - http.Error(w, "malformed url", http.StatusBadRequest) - return - } - - ok, err := s.enforcer.E.HasGroupingPolicy(actor.Did, group, domain) - if err != nil || !ok { - // we need a logged in user - log.Printf("%s does not have perms of a %s in domain %s", actor.Did, group, domain) - http.Error(w, "Forbiden", http.StatusUnauthorized) - return - } - - next.ServeHTTP(w, r) - }) - } -} - -func KnotOwner(s *State) middleware.Middleware { - return knotRoleMiddleware(s, "server:owner") -} - -func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // requires auth also - actor := s.oauth.GetUser(r) - if actor == nil { - // we need a logged in user - log.Printf("not logged in, redirecting") - http.Error(w, "Forbiden", http.StatusUnauthorized) - return - } - f, err := s.repoResolver.Resolve(r) - if err != nil { - http.Error(w, "malformed url", http.StatusBadRequest) - return - } - - ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) - if err != nil || !ok { - // we need a logged in user - log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) - http.Error(w, "Forbiden", http.StatusUnauthorized) - return - } - - next.ServeHTTP(w, r) - }) - } -} - -func StripLeadingAt(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - path := req.URL.EscapedPath() - if strings.HasPrefix(path, "/@") { - req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") - } - next.ServeHTTP(w, req) - }) -} - -func ResolveIdent(s *State) middleware.Middleware { - excluded := []string{"favicon.ico"} - - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - didOrHandle := chi.URLParam(req, "user") - if slices.Contains(excluded, didOrHandle) { - next.ServeHTTP(w, req) - return - } - - id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle) - if err != nil { - // invalid did or handle - log.Println("failed to resolve did/handle:", err) - w.WriteHeader(http.StatusNotFound) - return - } - - ctx := context.WithValue(req.Context(), "resolvedId", *id) - - next.ServeHTTP(w, req.WithContext(ctx)) - }) - } -} - -func ResolveRepo(s *State) middleware.Middleware { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - repoName := chi.URLParam(req, "repo") - id, ok := req.Context().Value("resolvedId").(identity.Identity) - if !ok { - log.Println("malformed middleware") - w.WriteHeader(http.StatusInternalServerError) - return - } - - repo, err := db.GetRepo(s.db, id.DID.String(), repoName) - if err != nil { - // invalid did or handle - log.Println("failed to resolve repo") - s.pages.Error404(w) - return - } - - ctx := context.WithValue(req.Context(), "knot", repo.Knot) - ctx = context.WithValue(ctx, "repoAt", repo.AtUri) - ctx = context.WithValue(ctx, "repoDescription", repo.Description) - ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) - next.ServeHTTP(w, req.WithContext(ctx)) - }) - } -} - -// middleware that is tacked on top of /{user}/{repo}/pulls/{pull} -func ResolvePull(s *State) middleware.Middleware { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - f, err := s.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to fully resolve repo", err) - http.Error(w, "invalid repo url", http.StatusNotFound) - return - } - - prId := chi.URLParam(r, "pull") - prIdInt, err := strconv.Atoi(prId) - if err != nil { - http.Error(w, "bad pr id", http.StatusBadRequest) - log.Println("failed to parse pr id", err) - return - } - - pr, err := db.GetPull(s.db, f.RepoAt, prIdInt) - if err != nil { - log.Println("failed to get pull and comments", err) - return - } - - ctx := context.WithValue(r.Context(), "pull", pr) - - if pr.IsStacked() { - stack, err := db.GetStack(s.db, pr.StackId) - if err != nil { - log.Println("failed to get stack", err) - return - } - abandonedPulls, err := db.GetAbandonedPulls(s.db, pr.StackId) - if err != nil { - log.Println("failed to get abandoned pulls", err) - return - } - - ctx = context.WithValue(ctx, "stack", stack) - ctx = context.WithValue(ctx, "abandonedPulls", abandonedPulls) - } - - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} - -// this should serve the go-import meta tag even if the path is technically -// a 404 like tangled.sh/oppi.li/go-git/v5 -func GoImport(s *State) middleware.Middleware { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - f, err := s.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to fully resolve repo", err) - http.Error(w, "invalid repo url", http.StatusNotFound) - return - } - - fullName := f.OwnerHandle() + "/" + f.RepoName - - if r.Header.Get("User-Agent") == "Go-http-client/1.1" { - if r.URL.Query().Get("go-get") == "1" { - html := fmt.Sprintf( - ``, - fullName, - fullName, - ) - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(html)) - return - } - } - - next.ServeHTTP(w, r) - }) - } -} diff --git a/appview/state/router.go b/appview/state/router.go index 7f076431..43b5c836 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -14,11 +14,19 @@ import ( func (s *State) Router() http.Handler { router := chi.NewRouter() + middleware := middleware.New( + s.oauth, + s.db, + s.enforcer, + s.repoResolver, + s.resolver, + s.pages, + ) router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { pat := chi.URLParam(r, "*") if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { - s.UserRouter().ServeHTTP(w, r) + s.UserRouter(&middleware).ServeHTTP(w, r) } else { // Check if the first path element is a valid handle without '@' or a flattened DID pathParts := strings.SplitN(pat, "/", 2) @@ -41,24 +49,24 @@ func (s *State) Router() http.Handler { return } } - s.StandardRouter().ServeHTTP(w, r) + s.StandardRouter(&middleware).ServeHTTP(w, r) } }) return router } -func (s *State) UserRouter() http.Handler { +func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { r := chi.NewRouter() // strip @ from user - r.Use(StripLeadingAt) + r.Use(middleware.StripLeadingAt) - r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) { + r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { r.Get("/", s.Profile) - r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) { - r.Use(GoImport(s)) + r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { + r.Use(mw.GoImport()) r.Get("/", s.RepoIndex) r.Get("/commits/{ref}", s.RepoLog) @@ -80,7 +88,7 @@ func (s *State) UserRouter() http.Handler { // additionally: only the uploader can truly delete an artifact // (record+blob will live on their pds) r.Group(func(r chi.Router) { - r.With(RepoPermissionMiddleware(s, "repo:push")) + r.With(mw.RepoPermissionMiddleware("repo:push")) r.Post("/upload", s.AttachArtifact) r.Delete("/{file}", s.DeleteArtifact) }) @@ -113,7 +121,7 @@ func (s *State) UserRouter() http.Handler { r.Use(middleware.AuthMiddleware(s.oauth)) r.Get("/", s.ForkRepo) r.Post("/", s.ForkRepo) - r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/sync", func(r chi.Router) { + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/sync", func(r chi.Router) { r.Post("/", s.SyncRepoFork) }) }) @@ -143,7 +151,7 @@ func (s *State) UserRouter() http.Handler { }) r.Route("/{pull}", func(r chi.Router) { - r.Use(ResolvePull(s)) + r.Use(mw.ResolvePull()) r.Get("/", s.RepoSinglePull) r.Route("/round/{round}", func(r chi.Router) { @@ -170,7 +178,7 @@ func (s *State) UserRouter() http.Handler { r.Post("/reopen", s.ReopenPull) // collaborators only r.Group(func(r chi.Router) { - r.Use(RepoPermissionMiddleware(s, "repo:push")) + r.Use(mw.RepoPermissionMiddleware("repo:push")) r.Post("/merge", s.MergePull) // maybe lock, etc. }) @@ -187,15 +195,15 @@ func (s *State) UserRouter() http.Handler { r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(s.oauth)) // repo description can only be edited by owner - r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) { + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) { r.Put("/", s.RepoDescription) r.Get("/", s.RepoDescription) r.Get("/edit", s.RepoDescriptionEdit) }) - r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) { + r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { r.Get("/", s.RepoSettings) - r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator) - r.With(RepoPermissionMiddleware(s, "repo:delete")).Delete("/delete", s.DeleteRepo) + r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", s.AddCollaborator) + r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", s.DeleteRepo) r.Put("/branches/default", s.SetDefaultBranch) }) }) @@ -209,7 +217,7 @@ func (s *State) UserRouter() http.Handler { return r } -func (s *State) StandardRouter() http.Handler { +func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r := chi.NewRouter() r.Handle("/static/*", s.pages.Static()) @@ -227,7 +235,7 @@ func (s *State) StandardRouter() http.Handler { r.Post("/init", s.InitKnotServer) r.Get("/", s.KnotServerInfo) r.Route("/member", func(r chi.Router) { - r.Use(KnotOwner(s)) + r.Use(mw.KnotOwner()) r.Get("/", s.ListMembers) r.Put("/", s.AddMember) r.Delete("/", s.RemoveMember)