Files
Stevan Freeborn cf79c8a626 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.
2026-08-15 22:07:00 -05:00

308 lines
8.0 KiB
Go

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))
}