diff --git a/cmd/projectreshoot/auth.go b/cmd/projectreshoot/auth.go new file mode 100644 index 0000000..c876cf4 --- /dev/null +++ b/cmd/projectreshoot/auth.go @@ -0,0 +1,52 @@ +package main + +import ( + "database/sql" + "projectreshoot/internal/config" + "projectreshoot/internal/handler" + "projectreshoot/internal/models" + "projectreshoot/pkg/contexts" + + "git.haelnorr.com/h/golib/hlog" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "github.com/pkg/errors" +) + +func setupAuth( + config *config.Config, + logger *hlog.Logger, + conn *sql.DB, + server *hws.Server, + ignoredPaths []string, +) (*hwsauth.Authenticator[*models.User], error) { + auth, err := hwsauth.NewAuthenticator( + models.GetUserFromID, + server, + conn, + logger, + handler.NewErrorPage, + ) + if err != nil { + return nil, errors.Wrap(err, "hwsauth.NewAuthenticator") + } + + auth.SSL = config.SSL + auth.AccessTokenExpiry = config.AccessTokenExpiry + auth.RefreshTokenExpiry = config.RefreshTokenExpiry + auth.TokenFreshTime = config.TokenFreshTime + auth.TrustedHost = config.TrustedHost + auth.SecretKey = config.SecretKey + auth.LandingPage = "/profile" + + auth.IgnorePaths(ignoredPaths...) + + err = auth.Initialise() + if err != nil { + return nil, errors.Wrap(err, "auth.Initialise") + } + + contexts.CurrentUser = auth.CurrentModel + + return auth, nil +} diff --git a/cmd/projectreshoot/httpserver.go b/cmd/projectreshoot/httpserver.go new file mode 100644 index 0000000..13d645a --- /dev/null +++ b/cmd/projectreshoot/httpserver.go @@ -0,0 +1,69 @@ +package main + +import ( + "database/sql" + "git.haelnorr.com/h/golib/hws" + "io/fs" + "net/http" + "projectreshoot/internal/config" + + "git.haelnorr.com/h/golib/hlog" + "git.haelnorr.com/h/golib/jwt" + "github.com/pkg/errors" +) + +func setupHttpServer( + staticFS *fs.FS, + config *config.Config, + logger *hlog.Logger, + conn *sql.DB, + tokenGen *jwt.TokenGenerator, +) (server *hws.Server, err error) { + if staticFS == nil { + return nil, errors.New("No filesystem provided") + } + fs := http.FS(*staticFS) + httpServer, err := hws.NewServer( + config.Host, + config.Port, + config.ReadHeaderTimeout, + config.WriteTimeout, + config.IdleTimeout, + config.GZIP, + ) + if err != nil { + return nil, errors.Wrap(err, "hws.NewServer") + } + + ignoredPaths := []string{ + "/static/css/output.css", + "/static/favicon.ico", + } + + auth, err := setupAuth(config, logger, conn, httpServer, ignoredPaths) + if err != nil { + return nil, errors.Wrap(err, "setupAuth") + } + + err = httpServer.AddLogger(logger) + if err != nil { + return nil, errors.Wrap(err, "httpServer.AddLogger") + } + + err = httpServer.LoggerIgnorePaths(ignoredPaths...) + if err != nil { + return nil, errors.Wrap(err, "httpServer.LoggerIgnorePaths") + } + + err = addRoutes(httpServer, &fs, config, logger, conn, tokenGen, auth) + if err != nil { + return nil, errors.Wrap(err, "addRoutes") + } + + err = addMiddleware(httpServer, auth) + if err != nil { + return nil, errors.Wrap(err, "httpServer.AddMiddleware") + } + + return httpServer, nil +} diff --git a/cmd/projectreshoot/middleware.go b/cmd/projectreshoot/middleware.go new file mode 100644 index 0000000..ca0c846 --- /dev/null +++ b/cmd/projectreshoot/middleware.go @@ -0,0 +1,23 @@ +package main + +import ( + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "projectreshoot/internal/models" + + "github.com/pkg/errors" +) + +func addMiddleware( + server *hws.Server, + auth *hwsauth.Authenticator[*models.User], +) error { + + err := server.AddMiddleware( + auth.Authenticate(), + ) + if err != nil { + return errors.Wrap(err, "server.AddMiddleware") + } + return nil +} diff --git a/cmd/projectreshoot/routes.go b/cmd/projectreshoot/routes.go new file mode 100644 index 0000000..233936b --- /dev/null +++ b/cmd/projectreshoot/routes.go @@ -0,0 +1,127 @@ +package main + +import ( + "database/sql" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "net/http" + "projectreshoot/internal/config" + "projectreshoot/internal/handler" + "projectreshoot/internal/models" + "projectreshoot/internal/view/page" + + "git.haelnorr.com/h/golib/hlog" + "git.haelnorr.com/h/golib/jwt" + "github.com/pkg/errors" +) + +func addRoutes( + server *hws.Server, + staticFS *http.FileSystem, + config *config.Config, + logger *hlog.Logger, + conn *sql.DB, + tokenGen *jwt.TokenGenerator, + auth *hwsauth.Authenticator[*models.User], +) error { + // Create the routes + routes := []hws.Route{ + { + Path: "/static/", + Method: hws.MethodGET, + Handler: http.StripPrefix("/static/", handler.StaticFS(staticFS, logger)), + }, + { + Path: "/", + Method: hws.MethodGET, + Handler: handler.Root(), + }, + { + Path: "/about", + Method: hws.MethodGET, + Handler: handler.HandlePage(page.About()), + }, + { + Path: "/login", + Method: hws.MethodGET, + Handler: auth.LogoutReq(handler.LoginPage(config.TrustedHost)), + }, + { + Path: "/login", + Method: hws.MethodPOST, + Handler: auth.LogoutReq(handler.LoginRequest(server, auth, conn)), + }, + { + Path: "/register", + Method: hws.MethodGET, + Handler: auth.LogoutReq(handler.RegisterPage(config.TrustedHost)), + }, + { + Path: "/register", + Method: hws.MethodPOST, + Handler: auth.LogoutReq(handler.RegisterRequest(config, logger, conn, tokenGen)), + }, + { + Path: "/logout", + Method: hws.MethodPOST, + Handler: handler.Logout(server, auth, conn), + }, + { + Path: "/reauthenticate", + Method: hws.MethodPOST, + Handler: auth.LoginReq(handler.Reauthenticate(server, auth, conn)), + }, + { + Path: "/profile", + Method: hws.MethodGET, + Handler: auth.LoginReq(handler.ProfilePage()), + }, + { + Path: "/account", + Method: hws.MethodGET, + Handler: auth.LoginReq(handler.AccountPage()), + }, + { + Path: "/account-select-page", + Method: hws.MethodPOST, + Handler: auth.LoginReq(handler.AccountSubpage()), + }, + { + Path: "/change-username", + Method: hws.MethodPOST, + Handler: auth.LoginReq(auth.FreshReq(handler.ChangeUsername(server, auth, conn))), + }, + { + Path: "/change-password", + Method: hws.MethodPOST, + Handler: auth.LoginReq(auth.FreshReq(handler.ChangePassword(server, auth, conn))), + }, + { + Path: "/change-bio", + Method: hws.MethodPOST, + Handler: auth.LoginReq(handler.ChangeBio(server, auth, conn)), + }, + { + Path: "/movies", + Method: hws.MethodGET, + Handler: handler.MoviesPage(), + }, + { + Path: "/search-movies", + Method: hws.MethodPOST, + Handler: handler.SearchMovies(config, logger), + }, + { + Path: "/movie/{movie_id}", + Method: hws.MethodGET, + Handler: handler.Movie(config, logger), + }, + } + + // Register the routes with the server + err := server.AddRoutes(routes...) + if err != nil { + return errors.Wrap(err, "server.AddRoutes") + } + return nil +} diff --git a/cmd/projectreshoot/run.go b/cmd/projectreshoot/run.go index d63c47a..34ab3e2 100644 --- a/cmd/projectreshoot/run.go +++ b/cmd/projectreshoot/run.go @@ -4,11 +4,9 @@ import ( "context" "fmt" "io" - "net/http" "os" "os/signal" "projectreshoot/internal/config" - "projectreshoot/internal/httpserver" "projectreshoot/pkg/embedfs" "sync" "time" @@ -62,16 +60,17 @@ func run(ctx context.Context, w io.Writer, args map[string]string, config *confi ) logger.Debug().Msg("Setting up HTTP server") - httpServer := httpserver.NewServer(config, logger, conn, tokenGen, &staticFS) + httpServer, err := setupHttpServer(&staticFS, config, logger, conn, tokenGen) + if err != nil { + return errors.Wrap(err, "setupHttpServer") + } // Runs the http server logger.Debug().Msg("Starting up the HTTP server") - go func() { - logger.Info().Str("address", httpServer.Addr).Msg("Listening for requests") - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error().Err(err).Msg("Error listening and serving") - } - }() + err = httpServer.Start() + if err != nil { + return errors.Wrap(err, "httpServer.Start") + } // Handles graceful shutdown var wg sync.WaitGroup @@ -80,9 +79,7 @@ func run(ctx context.Context, w io.Writer, args map[string]string, config *confi shutdownCtx := context.Background() shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second) defer cancel() - if err := httpServer.Shutdown(shutdownCtx); err != nil { - logger.Error().Err(err).Msg("Error shutting down server") - } + httpServer.Shutdown(shutdownCtx) }) wg.Wait() logger.Info().Msg("Shutting down") diff --git a/go.mod b/go.mod index 43b3d8b..f9f0564 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( git.haelnorr.com/h/golib/cookies v0.9.0 git.haelnorr.com/h/golib/env v0.9.0 git.haelnorr.com/h/golib/hlog v0.9.0 + git.haelnorr.com/h/golib/hws v0.1.0 + git.haelnorr.com/h/golib/hwsauth v0.2.0 git.haelnorr.com/h/golib/jwt v0.9.2 git.haelnorr.com/h/golib/tmdb v0.8.0 github.com/a-h/templ v0.3.977 diff --git a/go.sum b/go.sum index 62949aa..274faf0 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ git.haelnorr.com/h/golib/env v0.9.0 h1:Ahqr3PbHy7HdWEHUhylzIZy6Gg8mST5UdgKlU2RAh git.haelnorr.com/h/golib/env v0.9.0/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg= git.haelnorr.com/h/golib/hlog v0.9.0 h1:ib8n2MdmiRK2TF067p220kXmhDe9aAnlcsgpuv+QpvE= git.haelnorr.com/h/golib/hlog v0.9.0/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk= +git.haelnorr.com/h/golib/hws v0.1.0 h1:+0eNq1uGWrGfbS5AgHeGoGDjVfCWuaVu+1wBxgPqyOY= +git.haelnorr.com/h/golib/hws v0.1.0/go.mod h1:b2pbkMaebzmck9TxqGBGzTJPEcB5TWcEHwFknLE7dqM= +git.haelnorr.com/h/golib/hwsauth v0.2.0 h1:rLfTtxo0lBUMuWzEdoS1Y4i8/UiCzDZ5DS+6WC/C974= +git.haelnorr.com/h/golib/hwsauth v0.2.0/go.mod h1:d1oXUstDHqKwCXzcEMdHGC8yoT2S2gwpJkrEo8daCMs= git.haelnorr.com/h/golib/jwt v0.9.2 h1:l1Ow7DPGACAU54CnMP/NlZjdc4nRD1wr3xZ8a7taRvU= git.haelnorr.com/h/golib/jwt v0.9.2/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4= git.haelnorr.com/h/golib/tmdb v0.8.0 h1:OQ6M2TB8FHm8fJD7/ebfWm63Duzfp0kmFX9genEig34= diff --git a/internal/handler/account.go b/internal/handler/account.go index 9ba5c44..d1d15e7 100644 --- a/internal/handler/account.go +++ b/internal/handler/account.go @@ -6,13 +6,13 @@ import ( "net/http" "time" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" "projectreshoot/internal/models" "projectreshoot/internal/view/component/account" "projectreshoot/internal/view/page" - "projectreshoot/pkg/contexts" "git.haelnorr.com/h/golib/cookies" - "git.haelnorr.com/h/golib/hlog" "github.com/pkg/errors" ) @@ -45,7 +45,8 @@ func AccountSubpage() http.Handler { // Handles a request to change the users username func ChangeUsername( - logger *hlog.Logger, + server *hws.Server, + auth *hwsauth.Authenticator[*models.User], conn *sql.DB, ) http.Handler { return http.HandlerFunc( @@ -56,8 +57,7 @@ func ChangeUsername( // Start the transaction tx, err := conn.BeginTx(ctx, nil) if err != nil { - logger.Warn().Err(err).Msg("Error updating username") - w.WriteHeader(http.StatusServiceUnavailable) + server.ThrowWarn(w, hws.NewError(http.StatusServiceUnavailable, "Error updating username", err)) return } r.ParseForm() @@ -65,8 +65,7 @@ func ChangeUsername( unique, err := models.CheckUsernameUnique(tx, newUsername) if err != nil { tx.Rollback() - logger.Error().Err(err).Msg("Error updating username") - w.WriteHeader(http.StatusInternalServerError) + server.ThrowWarn(w, hws.NewError(http.StatusInternalServerError, "Error updating username", err)) return } if !unique { @@ -75,12 +74,11 @@ func ChangeUsername( Render(r.Context(), w) return } - user := contexts.GetUser(r.Context()) + user := auth.CurrentModel(r.Context()) err = user.ChangeUsername(tx, newUsername) if err != nil { tx.Rollback() - logger.Error().Err(err).Msg("Error updating username") - w.WriteHeader(http.StatusInternalServerError) + server.ThrowWarn(w, hws.NewError(http.StatusInternalServerError, "Error updating username", err)) return } tx.Commit() @@ -91,7 +89,8 @@ func ChangeUsername( // Handles a request to change the users bio func ChangeBio( - logger *hlog.Logger, + server *hws.Server, + auth *hwsauth.Authenticator[*models.User], conn *sql.DB, ) http.Handler { return http.HandlerFunc( @@ -102,8 +101,7 @@ func ChangeBio( // Start the transaction tx, err := conn.BeginTx(ctx, nil) if err != nil { - logger.Warn().Err(err).Msg("Error updating bio") - w.WriteHeader(http.StatusServiceUnavailable) + server.ThrowWarn(w, hws.NewError(http.StatusServiceUnavailable, "Error updating bio", err)) return } r.ParseForm() @@ -115,12 +113,11 @@ func ChangeBio( Render(r.Context(), w) return } - user := contexts.GetUser(r.Context()) + user := auth.CurrentModel(r.Context()) err = user.ChangeBio(tx, newBio) if err != nil { tx.Rollback() - logger.Error().Err(err).Msg("Error updating bio") - w.WriteHeader(http.StatusInternalServerError) + server.ThrowWarn(w, hws.NewError(http.StatusInternalServerError, "Error updating bio", err)) return } tx.Commit() @@ -145,7 +142,8 @@ func validateChangePassword( // Handles a request to change the users password func ChangePassword( - logger *hlog.Logger, + server *hws.Server, + auth *hwsauth.Authenticator[*models.User], conn *sql.DB, ) http.Handler { return http.HandlerFunc( @@ -156,8 +154,7 @@ func ChangePassword( // Start the transaction tx, err := conn.BeginTx(ctx, nil) if err != nil { - logger.Warn().Err(err).Msg("Error updating password") - w.WriteHeader(http.StatusServiceUnavailable) + server.ThrowWarn(w, hws.NewError(http.StatusServiceUnavailable, "Error updating password", err)) return } newPass, err := validateChangePassword(r) @@ -166,12 +163,11 @@ func ChangePassword( account.ChangePassword(err.Error()).Render(r.Context(), w) return } - user := contexts.GetUser(r.Context()) + user := auth.CurrentModel(r.Context()) err = user.SetPassword(tx, newPass) if err != nil { tx.Rollback() - logger.Error().Err(err).Msg("Error updating password") - w.WriteHeader(http.StatusInternalServerError) + server.ThrowWarn(w, hws.NewError(http.StatusInternalServerError, "Error updating password", err)) return } tx.Commit() diff --git a/internal/handler/errorpage.go b/internal/handler/errorpage.go index 4632cf9..e353e08 100644 --- a/internal/handler/errorpage.go +++ b/internal/handler/errorpage.go @@ -22,3 +22,21 @@ func ErrorPage( page.Error(errorCode, http.StatusText(errorCode), message[errorCode]). Render(r.Context(), w) } + +func NewErrorPage( + errorCode int, + w http.ResponseWriter, + r *http.Request, +) error { + message := map[int]string{ + 401: "You need to login to view this page.", + 403: "You do not have permission to view this page.", + 404: "The page or resource you have requested does not exist.", + 500: `An error occured on the server. Please try again, and if this + continues to happen contact an administrator.`, + 503: "The server is currently down for maintenance and should be back soon. =)", + } + w.WriteHeader(errorCode) + return page.Error(errorCode, http.StatusText(errorCode), message[errorCode]). + Render(r.Context(), w) +} diff --git a/internal/handler/login.go b/internal/handler/login.go index 0f369a8..8a8d92a 100644 --- a/internal/handler/login.go +++ b/internal/handler/login.go @@ -4,16 +4,16 @@ import ( "context" "database/sql" "net/http" + "strings" "time" - "projectreshoot/internal/config" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" "projectreshoot/internal/models" "projectreshoot/internal/view/component/form" "projectreshoot/internal/view/page" "git.haelnorr.com/h/golib/cookies" - "git.haelnorr.com/h/golib/hlog" - "git.haelnorr.com/h/golib/jwt" "github.com/pkg/errors" ) @@ -32,6 +32,9 @@ func validateLogin( err = user.CheckPassword(tx, formPassword) if err != nil { + if !strings.Contains(err.Error(), "Username or password incorrect") { + return nil, errors.Wrap(err, "user.CheckPassword") + } return nil, errors.New("Username or password incorrect") } return user, nil @@ -51,10 +54,9 @@ func checkRememberMe(r *http.Request) bool { // and on fail will return the login form again, passing the error to the // template for user feedback func LoginRequest( - config *config.Config, - logger *hlog.Logger, + server *hws.Server, + auth *hwsauth.Authenticator[*models.User], conn *sql.DB, - tokenGen *jwt.TokenGenerator, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -64,8 +66,7 @@ func LoginRequest( // Start the transaction tx, err := conn.BeginTx(ctx, nil) if err != nil { - logger.Warn().Err(err).Msg("Failed to set token cookies") - w.WriteHeader(http.StatusServiceUnavailable) + server.ThrowWarn(w, hws.NewError(http.StatusServiceUnavailable, "Login failed", err)) return } r.ParseForm() @@ -73,8 +74,7 @@ func LoginRequest( if err != nil { tx.Rollback() if err.Error() != "Username or password incorrect" { - logger.Warn().Caller().Err(err).Msg("Login request failed") - w.WriteHeader(http.StatusInternalServerError) + server.ThrowWarn(w, hws.NewError(http.StatusInternalServerError, "Login failed", err)) } else { form.LoginForm(err.Error()).Render(r.Context(), w) } @@ -82,11 +82,10 @@ func LoginRequest( } rememberMe := checkRememberMe(r) - err = jwt.SetTokenCookies(w, r, tokenGen, user.ID, true, rememberMe, config.SSL) + err = auth.Login(w, r, user, rememberMe) if err != nil { tx.Rollback() - w.WriteHeader(http.StatusInternalServerError) - logger.Warn().Caller().Err(err).Msg("Failed to set token cookies") + server.ThrowWarn(w, hws.NewError(http.StatusInternalServerError, "Login failed", err)) return } diff --git a/internal/handler/logout.go b/internal/handler/logout.go index 34b09f7..471ec56 100644 --- a/internal/handler/logout.go +++ b/internal/handler/logout.go @@ -3,83 +3,18 @@ package handler import ( "context" "database/sql" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" "net/http" - "strings" + "projectreshoot/internal/models" "time" - - "git.haelnorr.com/h/golib/cookies" - "git.haelnorr.com/h/golib/hlog" - "git.haelnorr.com/h/golib/jwt" - - "github.com/pkg/errors" ) -func revokeAccess( - tokenGen *jwt.TokenGenerator, - tx *sql.Tx, - atStr string, -) error { - aT, err := tokenGen.ValidateAccess(tx, atStr) - if err != nil { - if strings.Contains(err.Error(), "Token is expired") || - strings.Contains(err.Error(), "Token has been revoked") { - return nil // Token is expired, dont need to revoke it - } - return errors.Wrap(err, "jwt.ParseAccessToken") - } - err = aT.Revoke(tx) - if err != nil { - return errors.Wrap(err, "jwt.RevokeToken") - } - return nil -} - -func revokeRefresh( - tokenGen *jwt.TokenGenerator, - tx *sql.Tx, - rtStr string, -) error { - rT, err := tokenGen.ValidateRefresh(tx, rtStr) - if err != nil { - if strings.Contains(err.Error(), "Token is expired") || - strings.Contains(err.Error(), "Token has been revoked") { - return nil // Token is expired, dont need to revoke it - } - return errors.Wrap(err, "jwt.ParseRefreshToken") - } - err = rT.Revoke(tx) - if err != nil { - return errors.Wrap(err, "jwt.RevokeToken") - } - return nil -} - -// Retrieve and revoke the user's tokens -func revokeTokens( - tokenGen *jwt.TokenGenerator, - tx *sql.Tx, - r *http.Request, -) error { - // get the tokens from the cookies - atStr, rtStr := jwt.GetTokenCookies(r) - // revoke the refresh token first as the access token expires quicker - // only matters if there is an error revoking the tokens - err := revokeRefresh(tokenGen, tx, rtStr) - if err != nil { - return errors.Wrap(err, "revokeRefresh") - } - err = revokeAccess(tokenGen, tx, atStr) - if err != nil { - return errors.Wrap(err, "revokeAccess") - } - return nil -} - // Handle a logout request func Logout( + server *hws.Server, + auth *hwsauth.Authenticator[*models.User], conn *sql.DB, - tokenGen *jwt.TokenGenerator, - logger *hlog.Logger, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -88,21 +23,17 @@ func Logout( tx, err := conn.BeginTx(ctx, nil) if err != nil { - logger.Error().Err(err).Msg("Failed to start database transaction") - w.WriteHeader(http.StatusInternalServerError) + server.ThrowError(w, r, hws.NewError(http.StatusInternalServerError, "Logout failed", err)) return } defer tx.Rollback() - err = revokeTokens(tokenGen, tx, r) + err = auth.Logout(tx, w, r) if err != nil { - logger.Error().Err(err).Msg("Error occured on user logout") - w.WriteHeader(http.StatusInternalServerError) + server.ThrowError(w, r, hws.NewError(http.StatusInternalServerError, "Logout failed", err)) return } tx.Commit() - cookies.DeleteCookie(w, "access", "/") - cookies.DeleteCookie(w, "refresh", "/") w.Header().Set("HX-Redirect", "/login") }, ) diff --git a/internal/handler/movie.go b/internal/handler/movie.go index e33aeb5..8af5a9b 100644 --- a/internal/handler/movie.go +++ b/internal/handler/movie.go @@ -11,8 +11,8 @@ import ( ) func Movie( - logger *hlog.Logger, config *config.Config, + logger *hlog.Logger, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handler/movie_search.go b/internal/handler/movie_search.go index aad319d..d173309 100644 --- a/internal/handler/movie_search.go +++ b/internal/handler/movie_search.go @@ -11,8 +11,8 @@ import ( ) func SearchMovies( - logger *hlog.Logger, config *config.Config, + logger *hlog.Logger, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handler/reauthenticatate.go b/internal/handler/reauthenticatate.go index 46eb5dc..cec2fcb 100644 --- a/internal/handler/reauthenticatate.go +++ b/internal/handler/reauthenticatate.go @@ -6,90 +6,23 @@ import ( "net/http" "time" - "projectreshoot/internal/config" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "projectreshoot/internal/models" "projectreshoot/internal/view/component/form" - "projectreshoot/pkg/contexts" - - "git.haelnorr.com/h/golib/hlog" - "git.haelnorr.com/h/golib/jwt" "github.com/pkg/errors" ) -// Get the tokens from the request -func getTokens( - tokenGen *jwt.TokenGenerator, - tx *sql.Tx, - r *http.Request, -) (*jwt.AccessToken, *jwt.RefreshToken, error) { - // get the existing tokens from the cookies - atStr, rtStr := jwt.GetTokenCookies(r) - aT, err := tokenGen.ValidateAccess(tx, atStr) - if err != nil { - return nil, nil, errors.Wrap(err, "tokenGen.ValidateAccess") - } - rT, err := tokenGen.ValidateRefresh(tx, rtStr) - if err != nil { - return nil, nil, errors.Wrap(err, "tokenGen.ValidateRefresh") - } - return aT, rT, nil -} - -// Revoke the given token pair -func revokeTokenPair( - tx *sql.Tx, - aT *jwt.AccessToken, - rT *jwt.RefreshToken, -) error { - err := aT.Revoke(tx) - if err != nil { - return errors.Wrap(err, "aT.Revoke") - } - err = rT.Revoke(tx) - if err != nil { - return errors.Wrap(err, "rT.Revoke") - } - return nil -} - -// Issue new tokens for the user, invalidating the old ones -func refreshTokens( - config *config.Config, - tokenGen *jwt.TokenGenerator, - tx *sql.Tx, - w http.ResponseWriter, - r *http.Request, -) error { - aT, rT, err := getTokens(tokenGen, tx, r) - if err != nil { - return errors.Wrap(err, "getTokens") - } - rememberMe := map[string]bool{ - "session": false, - "exp": true, - }[aT.TTL] - // issue new tokens for the user - user := contexts.GetUser(r.Context()) - err = jwt.SetTokenCookies(w, r, tokenGen, user.ID, true, rememberMe, config.SSL) - if err != nil { - return errors.Wrap(err, "cookies.SetTokenCookies") - } - err = revokeTokenPair(tx, aT, rT) - if err != nil { - return errors.Wrap(err, "revokeTokenPair") - } - - return nil -} - // Validate the provided password func validatePassword( + auth *hwsauth.Authenticator[*models.User], tx *sql.Tx, r *http.Request, ) error { r.ParseForm() password := r.FormValue("password") - user := contexts.GetUser(r.Context()) + user := auth.CurrentModel(r.Context()) err := user.CheckPassword(tx, password) if err != nil { return errors.Wrap(err, "user.CheckPassword") @@ -99,10 +32,9 @@ func validatePassword( // Handle request to reauthenticate (i.e. make token fresh again) func Reauthenticate( - logger *hlog.Logger, - config *config.Config, + server *hws.Server, + auth *hwsauth.Authenticator[*models.User], conn *sql.DB, - tokenGen *jwt.TokenGenerator, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -112,21 +44,19 @@ func Reauthenticate( // Start the transaction tx, err := conn.BeginTx(ctx, nil) if err != nil { - logger.Error().Err(err).Msg("Failed to start transaction") - w.WriteHeader(http.StatusInternalServerError) + server.ThrowError(w, r, hws.NewError(http.StatusInternalServerError, "Failed to start transaction", err)) return } defer tx.Rollback() - err = validatePassword(tx, r) + err = validatePassword(auth, tx, r) if err != nil { w.WriteHeader(445) form.ConfirmPassword("Incorrect password").Render(r.Context(), w) return } - err = refreshTokens(config, tokenGen, tx, w, r) + err = auth.RefreshAuthTokens(tx, w, r) if err != nil { - logger.Error().Err(err).Msg("Failed to refresh user tokens") - w.WriteHeader(http.StatusInternalServerError) + server.ThrowError(w, r, hws.NewError(http.StatusInternalServerError, "Failed to refresh user tokens", err)) return } tx.Commit() diff --git a/internal/handler/register.go b/internal/handler/register.go index 6eb493d..2b04067 100644 --- a/internal/handler/register.go +++ b/internal/handler/register.go @@ -48,9 +48,9 @@ func validateRegistration( func RegisterRequest( config *config.Config, - tokenGen *jwt.TokenGenerator, logger *hlog.Logger, conn *sql.DB, + tokenGen *jwt.TokenGenerator, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -80,7 +80,7 @@ func RegisterRequest( } rememberMe := checkRememberMe(r) - err = jwt.SetTokenCookies(w, r, tokenGen, user.ID, true, rememberMe, config.SSL) + err = jwt.SetTokenCookies(w, r, tokenGen, user.ID(), true, rememberMe, config.SSL) if err != nil { tx.Rollback() w.WriteHeader(http.StatusInternalServerError) diff --git a/internal/handler/static.go b/internal/handler/static.go index 8b3c542..57b944d 100644 --- a/internal/handler/static.go +++ b/internal/handler/static.go @@ -1,52 +1,23 @@ package handler import ( + "git.haelnorr.com/h/golib/hws" "net/http" - "os" + + "git.haelnorr.com/h/golib/hlog" ) -// Wrapper for default FileSystem -type justFilesFilesystem struct { - fs http.FileSystem -} - -// Wrapper for default File -type neuteredReaddirFile struct { - http.File -} - -// Modifies the behavior of FileSystem.Open to return the neutered version of File -func (fs justFilesFilesystem) Open(name string) (http.File, error) { - f, err := fs.fs.Open(name) - if err != nil { - return nil, err - } - - // Check if the requested path is a directory - // and explicitly return an error to trigger a 404 - fileInfo, err := f.Stat() - if err != nil { - return nil, err - } - if fileInfo.IsDir() { - return nil, os.ErrNotExist - } - - return neuteredReaddirFile{f}, nil -} - -// Overrides the Readdir method of File to always return nil -func (f neuteredReaddirFile) Readdir(count int) ([]os.FileInfo, error) { - return nil, nil -} - // Handles requests for static files, without allowing access to the // directory viewer and returning 404 if an exact file is not found -func StaticFS(staticFS *http.FileSystem) http.Handler { +func StaticFS(staticFS *http.FileSystem, logger *hlog.Logger) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { - nfs := justFilesFilesystem{*staticFS} - fs := http.FileServer(nfs) + fs, err := hws.SafeFileServer(staticFS) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + logger.Error().Err(err).Msg("Failed to load file system") + return + } fs.ServeHTTP(w, r) }, ) diff --git a/internal/httpserver/routes.go b/internal/httpserver/routes.go deleted file mode 100644 index 12f4539..0000000 --- a/internal/httpserver/routes.go +++ /dev/null @@ -1,72 +0,0 @@ -package httpserver - -import ( - "database/sql" - "net/http" - - "projectreshoot/internal/config" - "projectreshoot/internal/handler" - "projectreshoot/internal/middleware" - "projectreshoot/internal/view/page" - - "git.haelnorr.com/h/golib/hlog" - "git.haelnorr.com/h/golib/jwt" -) - -// Add all the handled routes to the mux -func addRoutes( - mux *http.ServeMux, - logger *hlog.Logger, - config *config.Config, - tokenGen *jwt.TokenGenerator, - conn *sql.DB, - staticFS *http.FileSystem, -) { - route := mux.Handle - loggedIn := middleware.LoginReq - loggedOut := middleware.LogoutReq - fresh := middleware.FreshReq - - // Health check - mux.HandleFunc("GET /healthz", func(http.ResponseWriter, *http.Request) {}) - - // Static files - route("GET /static/", http.StripPrefix("/static/", handler.StaticFS(staticFS))) - - // Index page and unhandled catchall (404) - route("GET /", handler.Root()) - - // Static content, unprotected pages - route("GET /about", handler.HandlePage(page.About())) - - // Login page and handlers - route("GET /login", loggedOut(handler.LoginPage(config.TrustedHost))) - route("POST /login", loggedOut(handler.LoginRequest(config, logger, conn, tokenGen))) - - // Register page and handlers - route("GET /register", loggedOut(handler.RegisterPage(config.TrustedHost))) - route("POST /register", loggedOut(handler.RegisterRequest(config, tokenGen, logger, conn))) - - // Logout - route("POST /logout", handler.Logout(conn, tokenGen, logger)) - - // Reauthentication request - route("POST /reauthenticate", loggedIn(handler.Reauthenticate(logger, config, conn, tokenGen))) - - // Profile page - route("GET /profile", loggedIn(handler.ProfilePage())) - - // Account page - route("GET /account", loggedIn(handler.AccountPage())) - route("POST /account-select-page", loggedIn(handler.AccountSubpage())) - route("POST /change-username", loggedIn(fresh(handler.ChangeUsername(logger, conn)))) - route("POST /change-bio", loggedIn(handler.ChangeBio(logger, conn))) - route("POST /change-password", loggedIn(fresh(handler.ChangePassword(logger, conn)))) - - // Movies Search - route("GET /movies", handler.MoviesPage()) - route("POST /search-movies", handler.SearchMovies(logger, config)) - - // Movie page - route("GET /movie/{movie_id}", handler.Movie(logger, config)) -} diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go deleted file mode 100644 index c9fdc7d..0000000 --- a/internal/httpserver/server.go +++ /dev/null @@ -1,65 +0,0 @@ -package httpserver - -import ( - "database/sql" - "io/fs" - "net" - "net/http" - "time" - - "projectreshoot/internal/config" - "projectreshoot/internal/middleware" - - "git.haelnorr.com/h/golib/hlog" - "git.haelnorr.com/h/golib/jwt" -) - -func NewServer( - config *config.Config, - logger *hlog.Logger, - conn *sql.DB, - tokenGen *jwt.TokenGenerator, - staticFS *fs.FS, -) *http.Server { - fs := http.FS(*staticFS) - srv := createServer(config, logger, conn, tokenGen, &fs) - httpServer := &http.Server{ - Addr: net.JoinHostPort(config.Host, config.Port), - Handler: srv, - ReadHeaderTimeout: config.ReadHeaderTimeout * time.Second, - WriteTimeout: config.WriteTimeout * time.Second, - IdleTimeout: config.IdleTimeout * time.Second, - } - return httpServer -} - -// Returns a new http.Handler with all the routes and middleware added -func createServer( - config *config.Config, - logger *hlog.Logger, - conn *sql.DB, - tokenGen *jwt.TokenGenerator, - staticFS *http.FileSystem, -) http.Handler { - mux := http.NewServeMux() - addRoutes( - mux, - logger, - config, - tokenGen, - conn, - staticFS, - ) - var handler http.Handler = mux - // Add middleware here, must be added in reverse order of execution - // i.e. First in list will get executed last during the request handling - handler = middleware.Logging(logger, handler) - handler = middleware.Authentication(logger, config, conn, tokenGen, handler) - - // Gzip - handler = middleware.Gzip(handler, config.GZIP) - - // Start the timer for the request chain so logger can have accurate info - handler = middleware.StartTimer(handler) - return handler -} diff --git a/internal/middleware/authentication.go b/internal/middleware/authentication.go deleted file mode 100644 index b2e4830..0000000 --- a/internal/middleware/authentication.go +++ /dev/null @@ -1,143 +0,0 @@ -package middleware - -import ( - "context" - "database/sql" - "net/http" - "time" - - "projectreshoot/internal/config" - "projectreshoot/internal/handler" - "projectreshoot/internal/models" - "projectreshoot/pkg/contexts" - - "git.haelnorr.com/h/golib/cookies" - "git.haelnorr.com/h/golib/hlog" - "git.haelnorr.com/h/golib/jwt" - "github.com/pkg/errors" -) - -// Attempt to use a valid refresh token to generate a new token pair -func refreshAuthTokens( - config *config.Config, - tokenGen *jwt.TokenGenerator, - tx *sql.Tx, - w http.ResponseWriter, - req *http.Request, - ref *jwt.RefreshToken, -) (*models.User, error) { - user, err := models.GetUserFromID(tx, ref.SUB) - if err != nil { - return nil, errors.Wrap(err, "models.GetUser") - } - - rememberMe := map[string]bool{ - "session": false, - "exp": true, - }[ref.TTL] - - // Set fresh to true because new tokens coming from refresh request - err = jwt.SetTokenCookies(w, req, tokenGen, user.ID, false, rememberMe, config.SSL) - if err != nil { - return nil, errors.Wrap(err, "cookies.SetTokenCookies") - } - // New tokens sent, revoke the used refresh token - err = ref.Revoke(tx) - if err != nil { - return nil, errors.Wrap(err, "ref.Revoke") - } - // Return the authorized user - return user, nil -} - -// Check the cookies for token strings and attempt to authenticate them -func getAuthenticatedUser( - config *config.Config, - tokenGen *jwt.TokenGenerator, - tx *sql.Tx, - w http.ResponseWriter, - r *http.Request, -) (*contexts.AuthenticatedUser, error) { - // Get token strings from cookies - atStr, rtStr := jwt.GetTokenCookies(r) - if atStr == "" && rtStr == "" { - return nil, errors.New("No token strings provided") - } - // Attempt to parse the access token - aT, err := tokenGen.ValidateAccess(tx, atStr) - if err != nil { - // Access token invalid, attempt to parse refresh token - rT, err := tokenGen.ValidateRefresh(tx, rtStr) - if err != nil { - return nil, errors.Wrap(err, "tokenGen.ValidateRefresh") - } - // Refresh token valid, attempt to get a new token pair - user, err := refreshAuthTokens(config, tokenGen, tx, w, r, rT) - if err != nil { - return nil, errors.Wrap(err, "refreshAuthTokens") - } - // New token pair sent, return the authorized user - authUser := contexts.AuthenticatedUser{ - User: user, - Fresh: time.Now().Unix(), - } - return &authUser, nil - } - // Access token valid - user, err := models.GetUserFromID(tx, aT.SUB) - if err != nil { - return nil, errors.Wrap(err, "models.GetUser") - } - authUser := contexts.AuthenticatedUser{ - User: user, - Fresh: aT.Fresh, - } - return &authUser, nil -} - -// Attempt to authenticate the user and add their account details -// to the request context -func Authentication( - logger *hlog.Logger, - config *config.Config, - conn *sql.DB, - tokenGen *jwt.TokenGenerator, - next http.Handler, -) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/static/css/output.css" || - r.URL.Path == "/static/favicon.ico" { - next.ServeHTTP(w, r) - return - } - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - // Start the transaction - tx, err := conn.BeginTx(ctx, nil) - if err != nil { - // Failed to start transaction, skip auth - logger.Warn().Err(err). - Msg("Skipping Auth - unable to start a transaction") - handler.ErrorPage(http.StatusServiceUnavailable, w, r) - return - } - user, err := getAuthenticatedUser(config, tokenGen, tx, w, r) - if err != nil { - tx.Rollback() - // User auth failed, delete the cookies to avoid repeat requests - cookies.DeleteCookie(w, "access", "/") - cookies.DeleteCookie(w, "refresh", "/") - logger.Debug(). - Str("remote_addr", r.RemoteAddr). - Err(err). - Msg("Failed to authenticate user") - next.ServeHTTP(w, r) - return - } - tx.Commit() - uctx := contexts.SetUser(r.Context(), user) - newReq := r.WithContext(uctx) - next.ServeHTTP(w, newReq) - }) -} diff --git a/internal/middleware/gzip.go b/internal/middleware/gzip.go deleted file mode 100644 index 7d3a632..0000000 --- a/internal/middleware/gzip.go +++ /dev/null @@ -1,32 +0,0 @@ -package middleware - -import ( - "compress/gzip" - "io" - "net/http" - "strings" -) - -func Gzip(next http.Handler, useGzip bool) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || - !useGzip { - next.ServeHTTP(w, r) - return - } - w.Header().Set("Content-Encoding", "gzip") - gz := gzip.NewWriter(w) - defer gz.Close() - gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w} - next.ServeHTTP(gzw, r) - }) -} - -type gzipResponseWriter struct { - io.Writer - http.ResponseWriter -} - -func (w gzipResponseWriter) Write(b []byte) (int, error) { - return w.Writer.Write(b) -} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go deleted file mode 100644 index 82bbffd..0000000 --- a/internal/middleware/logging.go +++ /dev/null @@ -1,50 +0,0 @@ -package middleware - -import ( - "net/http" - "projectreshoot/internal/handler" - "projectreshoot/pkg/contexts" - "time" - - "git.haelnorr.com/h/golib/hlog" -) - -// Wraps the http.ResponseWriter, adding a statusCode field -type wrappedWriter struct { - http.ResponseWriter - statusCode int -} - -// Extends WriteHeader to the ResponseWriter to add the status code -func (w *wrappedWriter) WriteHeader(statusCode int) { - w.ResponseWriter.WriteHeader(statusCode) - w.statusCode = statusCode -} - -// Middleware to add logs to console with details of the request -func Logging(logger *hlog.Logger, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/static/css/output.css" || - r.URL.Path == "/static/favicon.ico" { - next.ServeHTTP(w, r) - return - } - start, err := contexts.GetStartTime(r.Context()) - if err != nil { - handler.ErrorPage(http.StatusInternalServerError, w, r) - return - } - wrapped := &wrappedWriter{ - ResponseWriter: w, - statusCode: http.StatusOK, - } - next.ServeHTTP(wrapped, r) - logger.Info(). - Int("status", wrapped.statusCode). - Str("method", r.Method). - Str("resource", r.URL.Path). - Dur("time_elapsed", time.Since(start)). - Str("remote_addr", r.Header.Get("X-Forwarded-For")). - Msg("Served") - }) -} diff --git a/internal/middleware/pageprotection.go b/internal/middleware/pageprotection.go deleted file mode 100644 index c2442e5..0000000 --- a/internal/middleware/pageprotection.go +++ /dev/null @@ -1,32 +0,0 @@ -package middleware - -import ( - "net/http" - "projectreshoot/internal/handler" - "projectreshoot/pkg/contexts" -) - -// Checks if the user is set in the context and shows 401 page if not logged in -func LoginReq(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user := contexts.GetUser(r.Context()) - if user == nil { - handler.ErrorPage(http.StatusUnauthorized, w, r) - return - } - next.ServeHTTP(w, r) - }) -} - -// Checks if the user is set in the context and redirects them to profile if -// they are logged in -func LogoutReq(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user := contexts.GetUser(r.Context()) - if user != nil { - http.Redirect(w, r, "/profile", http.StatusFound) - return - } - next.ServeHTTP(w, r) - }) -} diff --git a/internal/middleware/reauthentication.go b/internal/middleware/reauthentication.go deleted file mode 100644 index a5dcf00..0000000 --- a/internal/middleware/reauthentication.go +++ /dev/null @@ -1,21 +0,0 @@ -package middleware - -import ( - "net/http" - "projectreshoot/pkg/contexts" - "time" -) - -func FreshReq( - next http.Handler, -) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user := contexts.GetUser(r.Context()) - isFresh := time.Now().Before(time.Unix(user.Fresh, 0)) - if !isFresh { - w.WriteHeader(444) - return - } - next.ServeHTTP(w, r) - }) -} diff --git a/internal/middleware/start.go b/internal/middleware/start.go deleted file mode 100644 index f0235b1..0000000 --- a/internal/middleware/start.go +++ /dev/null @@ -1,18 +0,0 @@ -package middleware - -import ( - "net/http" - "projectreshoot/pkg/contexts" - "time" -) - -func StartTimer(next http.Handler) http.Handler { - return http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - ctx := contexts.SetStart(r.Context(), start) - newReq := r.WithContext(ctx) - next.ServeHTTP(w, newReq) - }, - ) -} diff --git a/internal/models/user.go b/internal/models/user.go index 3f93ea2..897ca96 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -8,12 +8,16 @@ import ( ) type User struct { - ID int // Integer ID (index primary key) + id int // Integer ID (index primary key) Username string // Username (unique) Created_at int64 // Epoch timestamp when the user was added to the database Bio string // Short byline set by the user } +func (u User) ID() int { + return u.id +} + // Uses bcrypt to set the users Password_hash from the given password func (user *User) SetPassword( tx *sql.Tx, @@ -25,7 +29,7 @@ func (user *User) SetPassword( } newPassword := string(hashedPassword) query := `UPDATE users SET password_hash = ? WHERE id = ?` - _, err = tx.Exec(query, newPassword, user.ID) + _, err = tx.Exec(query, newPassword, user.id) if err != nil { return errors.Wrap(err, "tx.Exec") } @@ -35,8 +39,8 @@ func (user *User) SetPassword( // Uses bcrypt to check if the given password matches the users Password_hash func (user *User) CheckPassword(tx *sql.Tx, password string) error { query := `SELECT password_hash FROM users WHERE id = ? LIMIT 1` - row := tx.QueryRow(query, user.ID) - hashedPassword := "" + row := tx.QueryRow(query, user.id) + var hashedPassword string err := row.Scan(&hashedPassword) if err != nil { return errors.Wrap(err, "row.Scan") @@ -51,7 +55,7 @@ func (user *User) CheckPassword(tx *sql.Tx, password string) error { // Change the user's username func (user *User) ChangeUsername(tx *sql.Tx, newUsername string) error { query := `UPDATE users SET username = ? WHERE id = ?` - _, err := tx.Exec(query, newUsername, user.ID) + _, err := tx.Exec(query, newUsername, user.id) if err != nil { return errors.Wrap(err, "tx.Exec") } @@ -61,7 +65,7 @@ func (user *User) ChangeUsername(tx *sql.Tx, newUsername string) error { // Change the user's bio func (user *User) ChangeBio(tx *sql.Tx, newBio string) error { query := `UPDATE users SET bio = ? WHERE id = ?` - _, err := tx.Exec(query, newBio, user.ID) + _, err := tx.Exec(query, newBio, user.id) if err != nil { return errors.Wrap(err, "tx.Exec") } diff --git a/internal/models/user_functions.go b/internal/models/user_functions.go index da4e5e0..d8e7db8 100644 --- a/internal/models/user_functions.go +++ b/internal/models/user_functions.go @@ -59,7 +59,7 @@ func scanUserRow(user *User, rows *sql.Rows) error { return errors.New("User not found") } err := rows.Scan( - &user.ID, + &user.id, &user.Username, &user.Created_at, &user.Bio, diff --git a/internal/view/component/account/changebio.templ b/internal/view/component/account/changebio.templ index 68e42d8..64b6d9b 100644 --- a/internal/view/component/account/changebio.templ +++ b/internal/view/component/account/changebio.templ @@ -3,11 +3,11 @@ package account import "projectreshoot/pkg/contexts" templ ChangeBio(err string, bio string) { + {{ user := contexts.CurrentUser(ctx) }} {{ - user := contexts.GetUser(ctx) - if bio == "" { - bio = user.Bio - } + if bio == "" { + bio = user.Bio + } }}