diff --git a/go.mod b/go.mod index 0bacb3b9..cdccfe75 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.23 require ( github.com/go-test/deep v1.0.8 github.com/gomarkdown/markdown v0.0.0-20240723152757-afa4a469d4f9 - github.com/gorilla/mux v1.8.0 github.com/kylelemons/godebug v1.1.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mtlynch/gorilla-handlers v1.5.2 diff --git a/go.sum b/go.sum index 690c4eef..3055df01 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,6 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/handlers v1.4.0/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/kylelemons/go-gypsy v1.0.0 h1:7/wQ7A3UL1bnqRMnZ6T8cwCOArfZCxFmb1iTxaOOo1s= github.com/kylelemons/go-gypsy v1.0.0/go.mod h1:chkXM0zjdpXOiqkCW1XcCHDfjfk14PH2KKkQWxfJUcU= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= diff --git a/handlers/db_dev.go b/handlers/db_dev.go index 490fa27c..b752e717 100644 --- a/handlers/db_dev.go +++ b/handlers/db_dev.go @@ -19,9 +19,14 @@ import ( // addDevRoutes adds debug routes that we only use during development or e2e // tests. func (s *Server) addDevRoutes() { - s.router.Use(assignSessionDB) - s.router.HandleFunc("/api/debug/db/populate-dummy-data", s.populateDummyData()).Methods(http.MethodGet) - s.router.HandleFunc("/api/debug/db/per-session", dbPerSessionPost()).Methods(http.MethodPost) + s.router.Handle("GET /api/debug/db/populate-dummy-data", s.populateDummyData()) + s.router.Handle("POST /api/debug/db/per-session", dbPerSessionPost()) +} + +// devMiddleware returns middleware that should wrap the entire router in dev +// builds (e.g., per-session database assignment). +func (s Server) devMiddleware(h http.Handler) http.Handler { + return assignSessionDB(h) } func (s Server) populateDummyData() http.HandlerFunc { diff --git a/handlers/db_prod.go b/handlers/db_prod.go index 1e04a4c0..3d122fd6 100644 --- a/handlers/db_prod.go +++ b/handlers/db_prod.go @@ -17,3 +17,8 @@ func (s Server) getDB(*http.Request) Store { func (s Server) getAuthenticator(_ *http.Request) Authenticator { return s.authenticator } + +// devMiddleware returns the handler unchanged in production builds. +func (s Server) devMiddleware(h http.Handler) http.Handler { + return h +} diff --git a/handlers/password_reset.go b/handlers/password_reset.go index c9afac45..b208b0db 100644 --- a/handlers/password_reset.go +++ b/handlers/password_reset.go @@ -8,7 +8,6 @@ import ( "net/http" "time" - "github.com/gorilla/mux" "github.com/mtlynch/screenjournal/v2/auth" "github.com/mtlynch/screenjournal/v2/handlers/parse" "github.com/mtlynch/screenjournal/v2/screenjournal" @@ -82,8 +81,7 @@ func (s Server) passwordResetAdminPost() http.HandlerFunc { func (s Server) passwordResetAdminDelete() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - token, err := parse.PasswordResetToken(vars["token"]) + token, err := parse.PasswordResetToken(r.PathValue("token")) if err != nil { http.Error(w, "Invalid password reset token", http.StatusBadRequest) return diff --git a/handlers/reactions.go b/handlers/reactions.go index 3d075b0f..329fd94b 100644 --- a/handlers/reactions.go +++ b/handlers/reactions.go @@ -7,8 +7,6 @@ import ( "net/http" "time" - "github.com/gorilla/mux" - "github.com/mtlynch/screenjournal/v2/handlers/parse" "github.com/mtlynch/screenjournal/v2/screenjournal" "github.com/mtlynch/screenjournal/v2/store" @@ -168,5 +166,5 @@ func parseReactionPostRequest(r *http.Request) (reactionPostRequest, error) { } func reactionIDFromRequestPath(r *http.Request) (screenjournal.ReactionID, error) { - return parse.ReactionID(mux.Vars(r)["reactionID"]) + return parse.ReactionID(r.PathValue("reactionID")) } diff --git a/handlers/routes.go b/handlers/routes.go index c98691f4..cb42b4d6 100644 --- a/handlers/routes.go +++ b/handlers/routes.go @@ -2,90 +2,130 @@ package handlers import "net/http" +func withMiddleware(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler { + for i := len(mw) - 1; i >= 0; i-- { + h = mw[i](h) + } + return h +} + +// withMiddlewareFunc is a convenience wrapper for http.HandlerFunc. +func withMiddlewareFunc(h http.HandlerFunc, mw ...func(http.Handler) http.Handler) http.Handler { + return withMiddleware(h, mw...) +} + func (s *Server) routes() { - s.router.HandleFunc("/api/auth", s.authPost()).Methods(http.MethodPost) - s.router.HandleFunc("/api/auth", s.authDelete()).Methods(http.MethodDelete) - s.router.HandleFunc("/api/users/{username}", s.usersPut()).Methods(http.MethodPut) - s.router.Use(s.populateAuthenticationContext) + // Unauthenticated APIs + s.router.Handle("POST /api/auth", s.authPost()) + s.router.Handle("DELETE /api/auth", s.authDelete()) + s.router.Handle("PUT /api/users/{username}", s.usersPut()) - adminApis := s.router.PathPrefix("/api/admin").Subrouter() - adminApis.Use(s.requireAuthenticationForAPI) - adminApis.Use(s.requireAdmin) - adminApis.HandleFunc("/repopulate/movies", s.repopulateMoviesGet()).Methods(http.MethodGet) - adminApis.HandleFunc("/repopulate/tv", s.repopulateTvShowsGet()).Methods(http.MethodGet) - adminApis.HandleFunc("/invites", s.invitesPost()).Methods(http.MethodPost) + // Admin APIs + adminAPI := func(h http.HandlerFunc) http.Handler { + return withMiddlewareFunc(h, s.requireAuthenticationForAPI, s.requireAdmin) + } + s.router.Handle("GET /api/admin/repopulate/movies", adminAPI(s.repopulateMoviesGet())) + s.router.Handle("GET /api/admin/repopulate/tv", adminAPI(s.repopulateTvShowsGet())) + s.router.Handle("POST /api/admin/invites", adminAPI(s.invitesPost())) - authenticatedApis := s.router.PathPrefix("/api").Subrouter() - authenticatedApis.Use(s.requireAuthenticationForAPI) - authenticatedApis.HandleFunc("/comments", s.commentsPost()).Methods(http.MethodPost) - authenticatedApis.HandleFunc("/comments/add", s.commentsAddGet()).Methods(http.MethodGet) - authenticatedApis.HandleFunc("/comments/edit", s.commentsEditGet()).Methods(http.MethodGet) - authenticatedApis.HandleFunc("/comments/{commentID}", s.commentsGet()).Methods(http.MethodGet) - authenticatedApis.HandleFunc("/comments/{commentID}", s.commentsPut()).Methods(http.MethodPut) - authenticatedApis.HandleFunc("/comments/{commentID}", s.commentsDelete()).Methods(http.MethodDelete) - authenticatedApis.HandleFunc("/search", s.searchGet()).Methods(http.MethodGet) + // Authenticated APIs + authAPI := func(h http.HandlerFunc) http.Handler { + return withMiddlewareFunc(h, s.requireAuthenticationForAPI) + } + s.router.Handle("POST /api/comments", authAPI(s.commentsPost())) + s.router.Handle("GET /api/comments/add", authAPI(s.commentsAddGet())) + s.router.Handle("GET /api/comments/edit", authAPI(s.commentsEditGet())) + s.router.Handle("GET /api/comments/{commentID}", authAPI(s.commentsGet())) + s.router.Handle("PUT /api/comments/{commentID}", authAPI(s.commentsPut())) + s.router.Handle("DELETE /api/comments/{commentID}", authAPI(s.commentsDelete())) + s.router.Handle("GET /api/search", authAPI(s.searchGet())) - static := s.router.PathPrefix("/").Subrouter() - static.PathPrefix("/css/").Handler(getStaticFilesHandler()).Methods(http.MethodGet) - static.PathPrefix("/js/").Handler(getStaticFilesHandler()).Methods(http.MethodGet) - static.PathPrefix("/third-party/").Handler(getStaticFilesHandler()).Methods(http.MethodGet) + // Static files + staticHandler := getStaticFilesHandler() + s.router.Handle("GET /css/", staticHandler) + s.router.Handle("GET /js/", staticHandler) + s.router.Handle("GET /third-party/", staticHandler) - adminViews := s.router.PathPrefix("/admin").Subrouter() - adminViews.Use(s.requireAuthenticationForView) - adminViews.Use(s.requireAdmin) - adminViews.Use(enforceContentSecurityPolicy) - adminViews.HandleFunc("/invites", s.invitesGet()).Methods(http.MethodGet) - adminViews.HandleFunc("/reset-password", s.passwordResetAdminGet()).Methods(http.MethodGet) + // Admin views + adminViewMW := func(h http.HandlerFunc) http.Handler { + return withMiddlewareFunc(h, s.requireAuthenticationForView, s.requireAdmin, enforceContentSecurityPolicy) + } + s.router.Handle("GET /admin/invites", adminViewMW(s.invitesGet())) + s.router.Handle("GET /admin/reset-password", adminViewMW(s.passwordResetAdminGet())) - views := s.router.PathPrefix("/").Subrouter() - views.Use(upgradeToHttps) - views.Use(enforceContentSecurityPolicy) - views.HandleFunc("/about", s.aboutGet()).Methods(http.MethodGet) - views.HandleFunc("/login", s.logInGet()).Methods(http.MethodGet) - views.HandleFunc("/sign-up", s.signUpGet()).Methods(http.MethodGet) - views.HandleFunc("/account/password-reset", s.accountPasswordResetGet()).Methods(http.MethodGet) - views.HandleFunc("/account/password-reset", s.accountPasswordResetPut()).Methods(http.MethodPut) - views.HandleFunc("/", s.indexGet()).Methods(http.MethodGet) + // Public views + viewMW := func(h http.HandlerFunc) http.Handler { + return withMiddlewareFunc(h, upgradeToHttps, enforceContentSecurityPolicy) + } + s.router.Handle("GET /about", viewMW(s.aboutGet())) + s.router.Handle("GET /login", viewMW(s.logInGet())) + s.router.Handle("GET /sign-up", viewMW(s.signUpGet())) + s.router.Handle("GET /account/password-reset", viewMW(s.accountPasswordResetGet())) + s.router.Handle("PUT /account/password-reset", viewMW(s.accountPasswordResetPut())) + s.router.Handle("GET /{$}", viewMW(s.indexGet())) - // Transitional subrouter as we get rid of the idea of separate API routes vs. - // view routes. - authenticatedRoutes := s.router.PathPrefix("/").Subrouter() - authenticatedRoutes.Use(s.requireAuthenticationForAPI) - authenticatedRoutes.Use(enforceContentSecurityPolicy) - authenticatedRoutes.HandleFunc("/account/notifications", s.accountNotificationsPut()).Methods(http.MethodPut) - authenticatedRoutes.HandleFunc("/account/password", s.accountChangePasswordPut()).Methods(http.MethodPut) - authenticatedRoutes.HandleFunc("/reviews", s.reviewsPost()).Methods(http.MethodPost) - authenticatedRoutes.HandleFunc("/reviews/{reviewID}", s.reviewsPut()).Methods(http.MethodPut) - authenticatedRoutes.HandleFunc("/reviews/{reviewID}", s.reviewsDelete()).Methods(http.MethodDelete) - authenticatedRoutes.HandleFunc("/reactions", s.reactionsPost()).Methods(http.MethodPost) - authenticatedRoutes.HandleFunc("/reactions/{reactionID}", s.reactionsDelete()).Methods(http.MethodDelete) + // Authenticated routes (transitional) + authRouteMW := func(h http.HandlerFunc) http.Handler { + return withMiddlewareFunc(h, s.requireAuthenticationForAPI, enforceContentSecurityPolicy) + } + s.router.Handle("PUT /account/notifications", authRouteMW(s.accountNotificationsPut())) + s.router.Handle("PUT /account/password", authRouteMW(s.accountChangePasswordPut())) + s.router.Handle("POST /reviews", authRouteMW(s.reviewsPost())) + s.router.Handle("PUT /reviews/{reviewID}", authRouteMW(s.reviewsPut())) + s.router.Handle("DELETE /reviews/{reviewID}", authRouteMW(s.reviewsDelete())) + s.router.Handle("POST /reactions", authRouteMW(s.reactionsPost())) + s.router.Handle("DELETE /reactions/{reactionID}", authRouteMW(s.reactionsDelete())) - // Transitional subrouter as we get rid of the idea of separate API routes vs. - // view routes. - adminRoutes := s.router.PathPrefix("/admin").Subrouter() - adminRoutes.Use(s.requireAuthenticationForAPI) - adminRoutes.Use(s.requireAdmin) - adminRoutes.Use(enforceContentSecurityPolicy) - adminRoutes.HandleFunc("/invites", s.invitesPost()).Methods(http.MethodPost) - adminRoutes.HandleFunc("/reset-password", s.passwordResetAdminPost()).Methods(http.MethodPost) - adminRoutes.HandleFunc("/reset-password/{token}", s.passwordResetAdminDelete()).Methods(http.MethodDelete) + // Admin routes (transitional) + adminRouteMW := func(h http.HandlerFunc) http.Handler { + return withMiddlewareFunc(h, s.requireAuthenticationForAPI, s.requireAdmin, enforceContentSecurityPolicy) + } + s.router.Handle("POST /admin/invites", adminRouteMW(s.invitesPost())) + s.router.Handle("POST /admin/reset-password", adminRouteMW(s.passwordResetAdminPost())) + s.router.Handle("DELETE /admin/reset-password/{token}", adminRouteMW(s.passwordResetAdminDelete())) - authenticatedViews := s.router.PathPrefix("/").Subrouter() - authenticatedViews.Use(s.requireAuthenticationForView) - authenticatedViews.Use(enforceContentSecurityPolicy) - authenticatedViews.HandleFunc("/account/change-password", s.accountChangePasswordGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/account/notifications", s.accountNotificationsGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/account/security", s.accountSecurityGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/activity", s.activityGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/movies/{movieID}", s.moviesReadGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/tv-shows/{tvShowID}", s.tvShowsReadGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/reviews", s.reviewsGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/reviews/by/{username}", s.reviewsGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/reviews/new", s.reviewsNewTitleSearchGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/reviews/new/tv/pick-season", s.reviewsNewPickSeasonGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/reviews/new/write", s.reviewsNewWriteReviewGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/reviews/{reviewID}/edit", s.reviewsEditGet()).Methods(http.MethodGet) - authenticatedViews.HandleFunc("/users", s.usersGet()).Methods(http.MethodGet) + // Authenticated views + authViewMW := func(h http.HandlerFunc) http.Handler { + return withMiddlewareFunc(h, s.requireAuthenticationForView, enforceContentSecurityPolicy) + } + s.router.Handle("GET /account/change-password", authViewMW(s.accountChangePasswordGet())) + s.router.Handle("GET /account/notifications", authViewMW(s.accountNotificationsGet())) + s.router.Handle("GET /account/security", authViewMW(s.accountSecurityGet())) + s.router.Handle("GET /activity", authViewMW(s.activityGet())) + s.router.Handle("GET /movies/{movieID}", authViewMW(s.moviesReadGet())) + s.router.Handle("GET /tv-shows/{tvShowID}", authViewMW(s.tvShowsReadGet())) + s.router.Handle("GET /reviews", authViewMW(s.reviewsGet())) + s.router.Handle("GET /reviews/new", authViewMW(s.reviewsNewTitleSearchGet())) + s.router.Handle("GET /reviews/new/tv/pick-season", authViewMW(s.reviewsNewPickSeasonGet())) + s.router.Handle("GET /reviews/new/write", authViewMW(s.reviewsNewWriteReviewGet())) + // "/reviews/by/{username}" and "/reviews/{reviewID}/edit" conflict in + // Go's ServeMux because neither pattern is more specific than the other + // (they both match "/reviews/by/edit"). We use a catch-all wildcard and + // dispatch manually. + s.router.Handle("GET /reviews/{segment}/{rest...}", authViewMW(s.reviewsSubpathDispatch())) + s.router.Handle("GET /users", authViewMW(s.usersGet())) s.addDevRoutes() } + +// reviewsSubpathDispatch handles the ambiguous /reviews/{segment}/{rest...} +// pattern, manually dispatching to either /reviews/by/{username} or +// /reviews/{reviewID}/edit. +func (s *Server) reviewsSubpathDispatch() http.HandlerFunc { + byUsername := s.reviewsGet() + editReview := s.reviewsEditGet() + return func(w http.ResponseWriter, r *http.Request) { + segment := r.PathValue("segment") + rest := r.PathValue("rest") + switch { + case segment == "by" && rest != "": + r.SetPathValue("username", rest) + byUsername.ServeHTTP(w, r) + case rest == "edit": + r.SetPathValue("reviewID", segment) + editReview.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + } +} diff --git a/handlers/server.go b/handlers/server.go index 926911ce..2052e2c2 100644 --- a/handlers/server.go +++ b/handlers/server.go @@ -4,8 +4,6 @@ import ( "context" "net/http" - "github.com/gorilla/mux" - "github.com/mtlynch/screenjournal/v2/handlers/sessions" "github.com/mtlynch/screenjournal/v2/metadata" "github.com/mtlynch/screenjournal/v2/screenjournal" @@ -39,7 +37,7 @@ type ( } Server struct { - router *mux.Router + router *http.ServeMux authenticator Authenticator announcer Announcer sessionManager SessionManager @@ -49,15 +47,15 @@ type ( ) // Router returns the underlying router interface for the server. -func (s Server) Router() *mux.Router { - return s.router +func (s Server) Router() http.Handler { + return s.devMiddleware(s.populateAuthenticationContext(s.router)) } // New creates a new server with all the state it needs to satisfy HTTP // requests. func New(authenticator Authenticator, announcer Announcer, sessionManager SessionManager, store Store, metadataFinder MetadataFinder) Server { s := Server{ - router: mux.NewRouter(), + router: http.NewServeMux(), authenticator: authenticator, announcer: announcer, sessionManager: sessionManager, diff --git a/handlers/url_parse.go b/handlers/url_parse.go index 2f6538fb..5d3bf7ad 100644 --- a/handlers/url_parse.go +++ b/handlers/url_parse.go @@ -4,8 +4,6 @@ import ( "errors" "net/http" - "github.com/gorilla/mux" - "github.com/mtlynch/screenjournal/v2/handlers/parse" "github.com/mtlynch/screenjournal/v2/screenjournal" ) @@ -32,7 +30,7 @@ func mediaTypeFromQueryParams(r *http.Request) (screenjournal.MediaType, error) } func movieIDFromRequestPath(r *http.Request) (screenjournal.MovieID, error) { - return parse.MovieIDFromString(mux.Vars(r)["movieID"]) + return parse.MovieIDFromString(r.PathValue("movieID")) } func movieIDFromQueryParams(r *http.Request) (screenjournal.MovieID, error) { @@ -45,7 +43,7 @@ func movieIDFromQueryParams(r *http.Request) (screenjournal.MovieID, error) { } func tvShowIDFromRequestPath(r *http.Request) (screenjournal.TvShowID, error) { - return parse.TvShowIDFromString(mux.Vars(r)["tvShowID"]) + return parse.TvShowIDFromString(r.PathValue("tvShowID")) } func tvShowIDFromQueryParams(r *http.Request) (screenjournal.TvShowID, error) { @@ -76,7 +74,7 @@ func tmdbIDFromQueryParams(r *http.Request) (screenjournal.TmdbID, error) { } func reviewIDFromRequestPath(r *http.Request) (screenjournal.ReviewID, error) { - return parse.ReviewIDFromString(mux.Vars(r)["reviewID"]) + return parse.ReviewIDFromString(r.PathValue("reviewID")) } func reviewIDFromQueryParams(r *http.Request) (screenjournal.ReviewID, error) { @@ -89,7 +87,7 @@ func reviewIDFromQueryParams(r *http.Request) (screenjournal.ReviewID, error) { } func commentIDFromRequestPath(r *http.Request) (screenjournal.CommentID, error) { - return parse.CommentID(mux.Vars(r)["commentID"]) + return parse.CommentID(r.PathValue("commentID")) } func commentIDFromQueryParams(r *http.Request) (screenjournal.CommentID, error) { @@ -102,7 +100,7 @@ func commentIDFromQueryParams(r *http.Request) (screenjournal.CommentID, error) } func usernameFromRequestPath(r *http.Request) (screenjournal.Username, error) { - return parse.Username(mux.Vars(r)["username"]) + return parse.Username(r.PathValue("username")) } func inviteCodeFromQueryParams(r *http.Request) (screenjournal.InviteCode, error) {