refactor: extract API handlers into internal packages
Move HTTP handler logic out of main into internal api and server packages, slimming main to a thin entrypoint. Bundles bug fixes: login nil-panic, empty-password validation, chirp sort ordering, duplicate-email 409, and restricting the file server to the assets directory.
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/StevanFreeborn/chirpy/internal/api"
|
||||
"github.com/StevanFreeborn/chirpy/internal/auth"
|
||||
"github.com/StevanFreeborn/chirpy/internal/database"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const accessTokenTTL = time.Hour
|
||||
|
||||
type createUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type userResponse struct {
|
||||
Id string `json:"id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Email string `json:"email"`
|
||||
IsChirpyRed bool `json:"is_chirpy_red"`
|
||||
}
|
||||
|
||||
// toUserResponse maps a database User to its API response shape.
|
||||
func toUserResponse(u database.User) userResponse {
|
||||
return userResponse{
|
||||
Id: u.ID.String(),
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
|
||||
Email: u.Email,
|
||||
IsChirpyRed: u.IsChirpyRed,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCreateUser registers a new user and returns it.
|
||||
func (s *Server) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
createUserRequest, err := api.DecodeJSON[createUserRequest](r)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
trimmedEmail := strings.TrimSpace(createUserRequest.Email)
|
||||
|
||||
if trimmedEmail == "" {
|
||||
api.WriteError(w, http.StatusBadRequest, "email is required. must be valid email address.")
|
||||
return
|
||||
}
|
||||
|
||||
trimmedPassword := strings.TrimSpace(createUserRequest.Password)
|
||||
|
||||
if trimmedPassword == "" {
|
||||
api.WriteError(w, http.StatusBadRequest, "password is required. must be non-empty string.")
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := auth.HashPassword(trimmedPassword)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusInternalServerError, "Uh oh we were unable to create a new user")
|
||||
return
|
||||
}
|
||||
|
||||
createUserParams := database.CreateUserParams{
|
||||
Email: trimmedEmail,
|
||||
HashedPassword: hashedPassword,
|
||||
}
|
||||
|
||||
createdUser, err := s.db.CreateUser(r.Context(), createUserParams)
|
||||
|
||||
if err != nil {
|
||||
var pgErr *pq.Error
|
||||
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
api.WriteError(w, http.StatusConflict, "A user with that email already exists")
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteError(w, http.StatusInternalServerError, "Uh oh we were unable to create a new user")
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteJSON(w, http.StatusCreated, toUserResponse(createdUser))
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (r *loginRequest) Validate() error {
|
||||
if strings.TrimSpace(r.Email) == "" || strings.TrimSpace(r.Password) == "" {
|
||||
return api.NewError("Email and password must be non-empty string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Id string `json:"id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Email string `json:"email"`
|
||||
IsChirpyRed bool `json:"is_chirpy_red"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// HandleLogin authenticates a user and returns new access and refresh tokens.
|
||||
func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
loginRequest, err := api.DecodeJSON[loginRequest](r)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := loginRequest.Validate(); err != nil {
|
||||
api.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
existingUser, err := s.db.GetUserByEmail(r.Context(), loginRequest.Email)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusUnauthorized, "Invalid login request")
|
||||
return
|
||||
}
|
||||
|
||||
isCorrectPassword, err := auth.CheckPasswordHash(loginRequest.Password, existingUser.HashedPassword)
|
||||
|
||||
if err != nil || !isCorrectPassword {
|
||||
api.WriteError(w, http.StatusUnauthorized, "Invalid login request")
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := auth.MakeJWT(existingUser.ID, s.jwtSecret, accessTokenTTL)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusInternalServerError, "Login failed")
|
||||
return
|
||||
}
|
||||
|
||||
createRefreshTokenParams := database.CreateRefreshTokenParams{
|
||||
Token: auth.MakeRefreshToken(),
|
||||
ExpiresAt: time.Now().Add(60 * 24 * time.Hour),
|
||||
RevokedAt: sql.NullTime{
|
||||
Valid: false,
|
||||
},
|
||||
UserID: existingUser.ID,
|
||||
}
|
||||
|
||||
createdRefreshToken, err := s.db.CreateRefreshToken(r.Context(), createRefreshTokenParams)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusInternalServerError, "Login failed")
|
||||
return
|
||||
}
|
||||
|
||||
user := toUserResponse(existingUser)
|
||||
|
||||
api.WriteJSON(w, http.StatusOK, loginResponse{
|
||||
Id: user.Id,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
Email: user.Email,
|
||||
IsChirpyRed: user.IsChirpyRed,
|
||||
Token: accessToken,
|
||||
RefreshToken: createdRefreshToken.Token,
|
||||
})
|
||||
}
|
||||
|
||||
type refreshTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// HandleRefresh exchanges a valid, unrevoked refresh token for a new access
|
||||
// token.
|
||||
func (s *Server) HandleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
refreshToken, err := auth.GetBearerToken(r.Header)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusUnauthorized, "Unable to refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
existingRefreshToken, err := s.db.GetRefreshTokenByToken(r.Context(), refreshToken)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusUnauthorized, "Unable to refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
if existingRefreshToken.ExpiresAt.Before(time.Now()) || existingRefreshToken.RevokedAt.Valid {
|
||||
api.WriteError(w, http.StatusUnauthorized, "Unable to refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := auth.MakeJWT(existingRefreshToken.UserID, s.jwtSecret, accessTokenTTL)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusInternalServerError, "Failed to refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: We should rotate the refresh token
|
||||
|
||||
api.WriteJSON(w, http.StatusOK, refreshTokenResponse{
|
||||
Token: accessToken,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleRevoke revokes a refresh token so it can no longer be used.
|
||||
func (s *Server) HandleRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
refreshToken, err := auth.GetBearerToken(r.Header)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusBadRequest, "No refresh token present in request")
|
||||
return
|
||||
}
|
||||
|
||||
existingRefreshToken, err := s.db.GetRefreshTokenByToken(r.Context(), refreshToken)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusNotFound, "Unable to revoke token")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.RevokeRefreshToken(r.Context(), existingRefreshToken.Token); err != nil {
|
||||
api.WriteError(w, http.StatusInternalServerError, "Unable to revoke token")
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteJSON(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (r *updateUserRequest) Validate() error {
|
||||
if strings.TrimSpace(r.Email) == "" || strings.TrimSpace(r.Password) == "" {
|
||||
return api.NewError("Email and password must be non-empty string")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleUpdateUser updates the authenticated user's email and password.
|
||||
func (s *Server) HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
accessToken, err := auth.GetBearerToken(r.Header)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusUnauthorized, "You are not authorized to perform this action")
|
||||
return
|
||||
}
|
||||
|
||||
requestUserId, err := auth.ValidateJWT(accessToken, s.jwtSecret)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusUnauthorized, "You are not authorized to perform this action")
|
||||
return
|
||||
}
|
||||
|
||||
updateUserRequest, err := api.DecodeJSON[updateUserRequest](r)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateUserRequest.Validate(); err != nil {
|
||||
api.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updatedHashedPassword, err := auth.HashPassword(updateUserRequest.Password)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusInternalServerError, "Unable to perform update")
|
||||
return
|
||||
}
|
||||
|
||||
updateUserParams := database.UpdateUserParams{
|
||||
ID: requestUserId,
|
||||
Email: updateUserRequest.Email,
|
||||
HashedPassword: updatedHashedPassword,
|
||||
}
|
||||
|
||||
updatedUser, err := s.db.UpdateUser(r.Context(), updateUserParams)
|
||||
|
||||
if err != nil {
|
||||
api.WriteError(w, http.StatusInternalServerError, "Unable to perform update")
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteJSON(w, http.StatusOK, toUserResponse(updatedUser))
|
||||
}
|
||||
Reference in New Issue
Block a user