diff --git a/README.md b/README.md index c09781d..e25b93e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The intention for this project is to work as a template, where the developer sho - CORS support - Request logging middleware - Native Go HTTP server (no external frameworks) -- HTMX for interactivity +- HTMX and ALPINE.js for front-end interactivity ## Requirements diff --git a/cmd/main.go b/cmd/main.go index 8bb7bdc..395f681 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -57,20 +57,26 @@ func main() { userService := services.NewUserService(db, queries) authService := services.NewAuthService(db, queries) - handler := handlers.NewHandler(userService, authService) + templateCache, err := handlers.NewTemplateCache() + if err != nil { + log.Fatal("Failed to parse templates:", err) + } + + handler := handlers.NewHandler(userService, authService, templateCache) mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./ui/static")))) - mux.HandleFunc("GET /{$}", handler.HomeHandler) + mux.HandleFunc("GET /{$}", handler.HomePageHandler) mux.HandleFunc("GET /login", handler.LoginPageHandler) - mux.HandleFunc("/user", handler.UserHandler) - mux.HandleFunc("/signup", handler.SignUpPageHandler) + mux.HandleFunc("GET /signup", handler.SignUpPageHandler) + mux.HandleFunc("GET /settings", handler.SettingsPageHandler) mux.HandleFunc("POST /api/login", handler.LoginHandler) mux.HandleFunc("POST /api/logout", handler.LogoutHandler) mux.HandleFunc("POST /api/signup", handler.SignUpHandler) + mux.HandleFunc("PATCH /api/user", handler.PatchUserHandler) muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux)) diff --git a/internal/database/queries/users.sql b/internal/database/queries/users.sql index d092fb1..e39d878 100644 --- a/internal/database/queries/users.sql +++ b/internal/database/queries/users.sql @@ -12,3 +12,11 @@ WHERE username = $1; INSERT INTO user_auth (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id, username, email, password_hash, created_at, updated_at; + +-- name: PatchUser :one +UPDATE user_auth +SET username = COALESCE(sqlc.narg(username), username), + email = COALESCE(sqlc.narg(email), email), + password_hash = COALESCE(sqlc.narg(password_hash), password_hash) +WHERE id = sqlc.arg(id) +RETURNING id, username, email, password_hash, created_at, updated_at; diff --git a/internal/database/sqlc/users.sql.go b/internal/database/sqlc/users.sql.go index 54f98bc..1974c0d 100644 --- a/internal/database/sqlc/users.sql.go +++ b/internal/database/sqlc/users.sql.go @@ -7,6 +7,7 @@ package sqlc import ( "context" + "database/sql" ) const insertUser = `-- name: InsertUser :one @@ -35,6 +36,41 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (UserAut return i, err } +const patchUser = `-- name: PatchUser :one +UPDATE user_auth +SET username = COALESCE($1, username), + email = COALESCE($2, email), + password_hash = COALESCE($3, password_hash) +WHERE id = $4 +RETURNING id, username, email, password_hash, created_at, updated_at +` + +type PatchUserParams struct { + Username sql.NullString + Email sql.NullString + PasswordHash sql.NullString + ID string +} + +func (q *Queries) PatchUser(ctx context.Context, arg PatchUserParams) (UserAuth, error) { + row := q.db.QueryRowContext(ctx, patchUser, + arg.Username, + arg.Email, + arg.PasswordHash, + arg.ID, + ) + var i UserAuth + err := row.Scan( + &i.ID, + &i.Username, + &i.Email, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const selectUserById = `-- name: SelectUserById :one SELECT id, username, email, password_hash, created_at, updated_at FROM user_auth diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go index 377edff..c568f43 100644 --- a/internal/handlers/handler.go +++ b/internal/handlers/handler.go @@ -5,9 +5,9 @@ import ( "go-backend/internal/auth" "go-backend/internal/database/sqlc" "go-backend/internal/services" + "html/template" "log" "net/http" - "text/template" ) // UserService and AuthService are defined here, on the consumer side, so @@ -18,6 +18,7 @@ type UserService interface { FindByID(ctx context.Context, id string) (*sqlc.UserAuth, error) FindByUsername(ctx context.Context, username string) (*sqlc.UserAuth, error) CreateUser(ctx context.Context, userInput sqlc.InsertUserParams) (*sqlc.UserAuth, error) + PatchUser(ctx context.Context, id string, input services.PatchUserInput) (*sqlc.UserAuth, error) } type AuthService interface { @@ -27,18 +28,44 @@ type AuthService interface { } type Handler struct { - users UserService - auth AuthService + users UserService + auth AuthService + templateCache map[string]*template.Template } -func NewHandler(users UserService, auth AuthService) *Handler { +// PageData wraps every page render so base.tmpl always has a stable place +// to check whether a user is logged in (.User), regardless of what +// page-specific data (.Data) the page template itself needs. +type PageData struct { + User *sqlc.UserAuth + Data any +} + +func NewHandler(users UserService, auth AuthService, templateCache map[string]*template.Template) *Handler { return &Handler{ - users: users, - auth: auth, + users: users, + auth: auth, + templateCache: templateCache, } } -func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) { +// render looks up the pre-parsed template for page (e.g. "index.tmpl") and +// executes it. It writes an error response itself on failure. +func (h *Handler) render(w http.ResponseWriter, page string, data PageData) { + ts, ok := h.templateCache[page] + if !ok { + log.Printf("template %s not found in cache", page) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if err := ts.ExecuteTemplate(w, "base", data); err != nil { + log.Print(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func (h *Handler) HomePageHandler(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session") if err != nil { @@ -69,27 +96,11 @@ func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) { return } - files := []string{ - "./ui/html/base.tmpl", - "./ui/html/pages/index.tmpl", - } - - ts, err := template.ParseFiles(files...) - if err != nil { - log.Print(err.Error()) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - err = ts.ExecuteTemplate(w, "base", user.Username) - if err != nil { - log.Print(err.Error()) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + h.render(w, "index.tmpl", PageData{User: user, Data: user.Username}) } func (h *Handler) LoginPageHandler(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie("session") + cookie, _ := r.Cookie("session") if cookie != nil { session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) @@ -110,27 +121,11 @@ func (h *Handler) LoginPageHandler(w http.ResponseWriter, r *http.Request) { } } - files := []string{ - "./ui/html/base.tmpl", - "./ui/html/pages/login.tmpl", - } - - ts, err := template.ParseFiles(files...) - if err != nil { - log.Print(err.Error()) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - err = ts.ExecuteTemplate(w, "base", nil) - if err != nil { - log.Print(err.Error()) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + h.render(w, "login.tmpl", PageData{}) } func (h *Handler) SignUpPageHandler(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie("session") + cookie, _ := r.Cookie("session") if cookie != nil { session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) @@ -151,21 +146,39 @@ func (h *Handler) SignUpPageHandler(w http.ResponseWriter, r *http.Request) { } } - files := []string{ - "./ui/html/base.tmpl", - "./ui/html/pages/signup.tmpl", + h.render(w, "signup.tmpl", PageData{}) +} + +func (h *Handler) SettingsPageHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return } - ts, err := template.ParseFiles(files...) + session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) + if err != nil { - log.Print(err.Error()) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - err = ts.ExecuteTemplate(w, "base", nil) - if err != nil { - log.Print(err.Error()) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + if session == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return } + + if bump { + http.SetCookie(w, auth.NewSessionCookie(cookie.Value, false)) + } + + user, err := h.users.FindByID(r.Context(), session.UserID) + + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + h.render(w, "settings.tmpl", PageData{User: user, Data: user}) } diff --git a/internal/handlers/templates.go b/internal/handlers/templates.go new file mode 100644 index 0000000..179c8eb --- /dev/null +++ b/internal/handlers/templates.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "html/template" + "path/filepath" +) + +// NewTemplateCache parses every page template once, each paired with the +// shared layout, and returns them keyed by page filename (e.g. "index.tmpl"). +// Handlers look up the pre-parsed template instead of re-parsing files on +// every request. +func NewTemplateCache() (map[string]*template.Template, error) { + cache := map[string]*template.Template{} + + pages, err := filepath.Glob("./ui/html/pages/*.tmpl") + if err != nil { + return nil, err + } + + for _, page := range pages { + name := filepath.Base(page) + + files := []string{ + "./ui/html/base.tmpl", + "./ui/html/navbar.tmpl", + page, + } + + ts, err := template.ParseFiles(files...) + if err != nil { + return nil, err + } + + cache[name] = ts + } + + return cache, nil +} diff --git a/internal/handlers/user.go b/internal/handlers/user.go index 69f5c78..f3b3be4 100644 --- a/internal/handlers/user.go +++ b/internal/handlers/user.go @@ -1,8 +1,11 @@ package handlers import ( - "encoding/json" + "errors" "go-backend/internal/auth" + "go-backend/internal/services" + "html/template" + "log" "net/http" ) @@ -12,53 +15,105 @@ type UserResponse struct { Email string `json:"email"` } -func (h *Handler) UserHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Get session token from cookie +// PatchUserHandler applies a partial update to the logged-in user. A field +// is only touched when its form key is present in the request body — an +// omitted key leaves that column unchanged, while an empty value is treated +// as an explicit (and rejected) attempt to blank out a required field. +func (h *Handler) PatchUserHandler(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session") if err != nil { http.Error(w, "Unauthorized, no session token", http.StatusUnauthorized) return } - // Validate session session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value) - if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - if session == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - if bump { http.SetCookie(w, auth.NewSessionCookie(cookie.Value, false)) } - // Get user info - user, err := h.users.FindByID(r.Context(), session.UserID) + if err := r.ParseForm(); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + input := services.PatchUserInput{} + + if r.PostForm.Has("username") { + v := r.PostFormValue("username") + if v == "" { + http.Error(w, "Username cannot be empty", http.StatusBadRequest) + return + } + input.Username = &v + } + + if r.PostForm.Has("email") { + v := r.PostFormValue("email") + if v == "" { + http.Error(w, "Email cannot be empty", http.StatusBadRequest) + return + } + input.Email = &v + } + + if r.PostForm.Has("current_password") { + v := r.PostFormValue("current_password") + input.CurrentPassword = &v + } + + if r.PostForm.Has("new_password") { + v := r.PostFormValue("new_password") + if v == "" { + http.Error(w, "New password cannot be empty", http.StatusBadRequest) + return + } + input.NewPassword = &v + } + + user, err := h.users.PatchUser(r.Context(), session.UserID, input) if err != nil { - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - if user == nil { - http.Error(w, "User not found", http.StatusNotFound) + switch { + case errors.Is(err, services.ErrCurrentPasswordRequired), errors.Is(err, services.ErrInvalidCurrentPassword): + http.Error(w, err.Error(), http.StatusBadRequest) + default: + log.Printf("Error patching user: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } return } - resp := UserResponse{ - ID: user.ID, - Username: user.Username, - Email: user.Email, + // The settings page targets the request's hx-target (a per-field error + // container) by default, so a plain success body would land there. Any + // field actually changed is instead pushed out-of-band to the element + // that displays its value, leaving the error container's swap empty. + data := patchUserSuccessData{PasswordChanged: input.NewPassword != nil} + if input.Username != nil { + data.Username = &user.Username + } + if input.Email != nil { + data.Email = &user.Email } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + patchUserSuccessTmpl.Execute(w, data) } + +type patchUserSuccessData struct { + Username *string + Email *string + PasswordChanged bool +} + +var patchUserSuccessTmpl = template.Must(template.New("patchUserSuccess").Parse(`` + + `{{if .Username}}

Username: {{.Username}}

{{end}}` + + `{{if .Email}}

Email: {{.Email}}

{{end}}` + + `{{if .PasswordChanged}}Password updated.{{end}}`, +)) diff --git a/internal/services/user.go b/internal/services/user.go index 051fa62..6c15fc0 100644 --- a/internal/services/user.go +++ b/internal/services/user.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" + "go-backend/internal/auth" "go-backend/internal/database/sqlc" ) @@ -55,3 +56,59 @@ func (s *UserService) CreateUser(ctx context.Context, userInput sqlc.InsertUserP return &user, nil } + +var ( + ErrCurrentPasswordRequired = errors.New("current password is required to set a new password") + ErrInvalidCurrentPassword = errors.New("current password is incorrect") +) + +// PatchUserInput carries a partial update: a nil field means "leave this +// field alone", distinguishing "not provided" from a provided zero value, +// since Go has no nullish type to express that on its own. +type PatchUserInput struct { + Username *string + Email *string + CurrentPassword *string + NewPassword *string +} + +func (s *UserService) PatchUser(ctx context.Context, id string, patchUserInput PatchUserInput) (*sqlc.UserAuth, error) { + params := sqlc.PatchUserParams{ID: id} + + if patchUserInput.Username != nil { + params.Username = sql.NullString{String: *patchUserInput.Username, Valid: true} + } + + if patchUserInput.Email != nil { + params.Email = sql.NullString{String: *patchUserInput.Email, Valid: true} + } + + if patchUserInput.NewPassword != nil { + if patchUserInput.CurrentPassword == nil { + return nil, ErrCurrentPasswordRequired + } + + user, err := s.queries.SelectUserById(ctx, id) + if err != nil { + return nil, err + } + + if !auth.VerifyPassword(user.PasswordHash, *patchUserInput.CurrentPassword) { + return nil, ErrInvalidCurrentPassword + } + + newHash, err := auth.HashPassword(*patchUserInput.NewPassword) + if err != nil { + return nil, err + } + + params.PasswordHash = sql.NullString{String: newHash, Valid: true} + } + + user, err := s.queries.PatchUser(ctx, params) + if err != nil { + return nil, err + } + + return &user, nil +} diff --git a/ui/html/base.tmpl b/ui/html/base.tmpl index f4a70a1..e633e9c 100644 --- a/ui/html/base.tmpl +++ b/ui/html/base.tmpl @@ -17,9 +17,13 @@ href="https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700" /> + -
{{template "main" .}}
+ {{if .User}} + {{template "navbar"}} + {{end}} +
{{template "main" .Data}}
diff --git a/ui/html/navbar.tmpl b/ui/html/navbar.tmpl new file mode 100644 index 0000000..5986f47 --- /dev/null +++ b/ui/html/navbar.tmpl @@ -0,0 +1,7 @@ +{{define "navbar"}} + +{{end}} \ No newline at end of file diff --git a/ui/html/pages/index.tmpl b/ui/html/pages/index.tmpl index 57cc23e..9721d40 100644 --- a/ui/html/pages/index.tmpl +++ b/ui/html/pages/index.tmpl @@ -2,5 +2,4 @@ {{define "main"}}

Welcome {{.}}

- {{end}} diff --git a/ui/html/pages/login.tmpl b/ui/html/pages/login.tmpl index 281090b..5a19314 100644 --- a/ui/html/pages/login.tmpl +++ b/ui/html/pages/login.tmpl @@ -1,4 +1,4 @@ -{{define "title"}}Welcome{{end}} +{{define "title"}}Login{{end}} {{define "main"}}
diff --git a/ui/html/pages/settings.tmpl b/ui/html/pages/settings.tmpl new file mode 100644 index 0000000..5c5610f --- /dev/null +++ b/ui/html/pages/settings.tmpl @@ -0,0 +1,91 @@ +{{define "title"}}Settings{{end}} + +{{define "main"}} +
+

Settings

+ +
+

+ Username: {{.Username}} +

+ +
+ + + +
+ + +
+
+ +
+

+ Email: {{.Email}} +

+
+ + + +
+ + +
+
+ + +

Change password

+
+
+
+ + +
+ +
+ + +
+ + +
+
+{{end}} diff --git a/ui/html/pages/signup.tmpl b/ui/html/pages/signup.tmpl index 3b3fe97..3d30fe8 100644 --- a/ui/html/pages/signup.tmpl +++ b/ui/html/pages/signup.tmpl @@ -1,4 +1,4 @@ -{{define "title"}}Welcome{{end}} +{{define "title"}}Sign up{{end}} {{define "main"}}