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)