forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

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
···
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,
+
}
+
}
+
+
type middlewareFunc func(http.Handler) http.Handler
-
func AuthMiddleware(a *oauth.OAuth) Middleware {
+
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) {
···
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(
+
`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`,
+
fullName,
+
fullName,
+
)
+
w.Header().Set("Content-Type", "text/html")
+
w.Write([]byte(html))
+
return
+
}
+
}
+
+
next.ServeHTTP(w, r)
+
})
+
}
+
}
-226
appview/state/middleware.go
···
-
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(
-
`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`,
-
fullName,
-
fullName,
-
)
-
w.Header().Set("Content-Type", "text/html")
-
w.Write([]byte(html))
-
return
-
}
-
}
-
-
next.ServeHTTP(w, r)
-
})
-
}
-
}
+25 -17
appview/state/router.go
···
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)
···
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)
···
// 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)
})
···
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)
})
})
···
})
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) {
···
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.
})
···
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)
})
})
···
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())
···
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)