Skip to content
This repository was archived by the owner on Aug 8, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
11 changes: 8 additions & 3 deletions handlers/db_dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions handlers/db_prod.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 1 addition & 3 deletions handlers/password_reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions handlers/reactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"))
}
190 changes: 115 additions & 75 deletions handlers/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
10 changes: 4 additions & 6 deletions handlers/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -39,7 +37,7 @@ type (
}

Server struct {
router *mux.Router
router *http.ServeMux
authenticator Authenticator
announcer Announcer
sessionManager SessionManager
Expand All @@ -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,
Expand Down
12 changes: 5 additions & 7 deletions handlers/url_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down