diff --git a/internal/api/json.go b/internal/api/json.go new file mode 100644 index 0000000..2737b8c --- /dev/null +++ b/internal/api/json.go @@ -0,0 +1,58 @@ +// Package api provides small HTTP helpers shared by the HTTP handlers. +package api + +import ( + "encoding/json" + "net/http" +) + +// Error is an error that serializes to the shape {"error": message}. +type Error struct { + Err string `json:"error"` +} + +// Error returns the error message. +func (e Error) Error() string { return e.Err } + +// NewError returns an Error with the given message. +func NewError(msg string) error { return Error{Err: msg} } + +// WriteJSON writes a JSON response. A nil payload writes only the status code. +func WriteJSON(w http.ResponseWriter, status int, payload any) { + if payload == nil { + w.WriteHeader(status) + return + } + + data, err := json.Marshal(payload) + + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"Failed to encode JSON response"}`)) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) +} + +// WriteError writes an error response with the given status code. +func WriteError(w http.ResponseWriter, status int, msg string) { + WriteJSON(w, status, Error{Err: msg}) +} + +// DecodeJSON decodes a JSON request body into a new value of type T. +func DecodeJSON[T any](r *http.Request) (*T, error) { + defer r.Body.Close() + + decoder := json.NewDecoder(r.Body) + var data *T + + if err := decoder.Decode(&data); err != nil { + return nil, NewError("Failed to deserialize request") + } + + return data, nil +} diff --git a/internal/api/json_test.go b/internal/api/json_test.go new file mode 100644 index 0000000..7913057 --- /dev/null +++ b/internal/api/json_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWriteError(t *testing.T) { + rec := httptest.NewRecorder() + WriteError(rec, http.StatusBadRequest, "nope") + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + + contentType := rec.Header().Get("Content-Type") + + if !strings.HasPrefix(contentType, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", contentType) + } + + var body map[string]string + + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + + if body["error"] != "nope" { + t.Fatalf("error message = %q, want %q", body["error"], "nope") + } +} + +func TestWriteJSONNilPayload(t *testing.T) { + rec := httptest.NewRecorder() + WriteJSON(rec, http.StatusNoContent, nil) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + + if rec.Body.Len() != 0 { + t.Fatalf("body = %q, want empty", rec.Body.String()) + } +} + +func TestDecodeJSON(t *testing.T) { + type userRequest struct { + Email string `json:"email"` + Password string `json:"password"` + } + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"email":"alice@example.com","password":"hunter2"}`)) + + got, err := DecodeJSON[userRequest](req) + + if err != nil { + t.Fatalf("DecodeJSON returned error: %v", err) + } + + if got == nil || got.Email != "alice@example.com" || got.Password != "hunter2" { + t.Fatalf("DecodeJSON = %+v, want alice@example.com/hunter2", got) + } +} + +func TestDecodeJSONInvalid(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("not json")) + + _, err := DecodeJSON[struct{}](req) + + if err == nil { + t.Fatal("DecodeJSON returned nil error for invalid body") + } + + if err.Error() != "Failed to deserialize request" { + t.Fatalf("error = %q, want %q", err.Error(), "Failed to deserialize request") + } +} + +func TestErrorSerializesToJSON(t *testing.T) { + rec := httptest.NewRecorder() + WriteJSON(rec, http.StatusConflict, Error{Err: "boom"}) + + if !strings.Contains(rec.Body.String(), `"boom"`) { + t.Fatalf("body = %q, want it to contain the error message", rec.Body.String()) + } +} diff --git a/internal/server/handlers_admin.go b/internal/server/handlers_admin.go new file mode 100644 index 0000000..25e42e1 --- /dev/null +++ b/internal/server/handlers_admin.go @@ -0,0 +1,74 @@ +package server + +import ( + "fmt" + "net/http" + + "github.com/StevanFreeborn/chirpy/internal/api" +) + +// handleFiles serves static assets and counts requests for the metrics page. +func (s *Server) handleFiles(prefix string) http.Handler { + fileServer := http.StripPrefix(prefix, http.FileServer(http.Dir("./assets"))) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.fileServerHits.Add(1) + fileServer.ServeHTTP(w, r) + }) +} + +// HandleIndex serves the static landing page and counts the visit. +func (s *Server) HandleIndex(w http.ResponseWriter, r *http.Request) { + s.fileServerHits.Add(1) + + w.Header().Add("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write(s.indexHTML) +} + +// HandleHealthChecks reports that the service is up. +func (s *Server) HandleHealthChecks(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +// HandleReset clears the file server hit counter and deletes all users. It is +// only available when the platform is "dev". +func (s *Server) HandleReset(w http.ResponseWriter, r *http.Request) { + if s.platform != "dev" { + w.WriteHeader(http.StatusForbidden) + return + } + + s.fileServerHits.Store(0) + + if err := s.db.DeleteAllUsers(r.Context()); err != nil { + api.WriteError(w, http.StatusInternalServerError, "Failed to reset database") + return + } + + w.Header().Add("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +// HandleMetrics renders the admin page showing how many times the file server +// has been hit. +func (s *Server) HandleMetrics(w http.ResponseWriter, r *http.Request) { + hits := s.fileServerHits.Load() + + w.Header().Add("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + template := ` + +
+Chirpy has been visited %d times!
+ + + ` + + fmt.Fprintf(w, template, hits) +} diff --git a/internal/server/handlers_chirps.go b/internal/server/handlers_chirps.go new file mode 100644 index 0000000..19f1f7a --- /dev/null +++ b/internal/server/handlers_chirps.go @@ -0,0 +1,205 @@ +package server + +import ( + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/StevanFreeborn/chirpy/internal/api" + "github.com/StevanFreeborn/chirpy/internal/auth" + "github.com/StevanFreeborn/chirpy/internal/database" + "github.com/google/uuid" +) + +const ( + ascending = "asc" + descending = "desc" +) + +type createChirpRequest struct { + Body string `json:"body"` + UserId string `json:"user_id"` +} + +type chirpResponse struct { + Id string `json:"id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Body string `json:"body"` + UserId string `json:"user_id"` +} + +// toChirpResponse maps a database Chirp to its API response shape. +func toChirpResponse(c database.Chirp) chirpResponse { + return chirpResponse{ + Id: c.ID.String(), + CreatedAt: c.CreatedAt.Format(time.RFC3339), + UpdatedAt: c.UpdatedAt.Format(time.RFC3339), + Body: c.Body, + UserId: c.UserID.String(), + } +} + +// HandleCreateChirp creates a chirp for the authenticated user. +func (s *Server) HandleCreateChirp(w http.ResponseWriter, r *http.Request) { + bearerToken, 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(bearerToken, s.jwtSecret) + + if err != nil { + api.WriteError(w, http.StatusUnauthorized, "You are not authorized to perform this action") + return + } + + createChirpRequest, err := api.DecodeJSON[createChirpRequest](r) + + if err != nil { + api.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + + if len(createChirpRequest.Body) > 140 { + api.WriteError(w, http.StatusBadRequest, "Chirp is too long") + return + } + + createChirpParams := database.CreateChirpParams{ + Body: cleanProfanity(createChirpRequest.Body), + UserID: requestUserId, + } + + createdChirp, err := s.db.CreateChirp(r.Context(), createChirpParams) + + if err != nil { + api.WriteError(w, http.StatusInternalServerError, "Failed to create chirp. 🤷🏻♂️") + return + } + + api.WriteJSON(w, http.StatusCreated, toChirpResponse(createdChirp)) +} + +// HandleGetChirps lists chirps, optionally filtered by author_id and sorted by +// the sort query parameter. +func (s *Server) HandleGetChirps(w http.ResponseWriter, r *http.Request) { + authorId := r.URL.Query().Get("author_id") + sortDir := r.URL.Query().Get("sort") + + hasAuthorId := strings.TrimSpace(authorId) != "" + hasSort := strings.TrimSpace(sortDir) != "" + + validAuthorId, err := uuid.Parse(authorId) + + if hasAuthorId && err != nil { + api.WriteError(w, http.StatusBadRequest, "Author id must be a valid uuid") + return + } + + if hasSort && sortDir != ascending && sortDir != descending { + api.WriteError(w, http.StatusBadRequest, fmt.Sprintf("Sort direction must be either '%s' or '%s'", ascending, descending)) + return + } + + if !hasSort { + sortDir = ascending + } + + var existingChirps []database.Chirp + + if hasAuthorId { + existingChirps, err = s.db.GetChirpsByAuthor(r.Context(), validAuthorId) + } else { + existingChirps, err = s.db.GetAllChirps(r.Context()) + } + + if err != nil { + api.WriteError(w, http.StatusInternalServerError, "Unable to retrieve chirps") + return + } + + slices.SortFunc(existingChirps, func(a, b database.Chirp) int { + if sortDir == descending { + return b.CreatedAt.Compare(a.CreatedAt) + } + + return a.CreatedAt.Compare(b.CreatedAt) + }) + + chirps := make([]chirpResponse, 0, len(existingChirps)) + + for _, c := range existingChirps { + chirps = append(chirps, toChirpResponse(c)) + } + + api.WriteJSON(w, http.StatusOK, chirps) +} + +// HandleGetChirp returns a single chirp by id. +func (s *Server) HandleGetChirp(w http.ResponseWriter, r *http.Request) { + chirpId := r.PathValue("id") + validChirpId, err := uuid.Parse(chirpId) + + if err != nil { + api.WriteError(w, http.StatusBadRequest, "Chirp id is not valid. id must be valid UUID.") + return + } + + existingChirp, err := s.db.GetChirpById(r.Context(), validChirpId) + + if err != nil { + api.WriteError(w, http.StatusNotFound, "No chirp with given id found.") + return + } + + api.WriteJSON(w, http.StatusOK, toChirpResponse(existingChirp)) +} + +// HandleDeleteChirp deletes a chirp owned by the authenticated user. +func (s *Server) HandleDeleteChirp(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 + } + + chirpId := r.PathValue("id") + validChirpId, err := uuid.Parse(chirpId) + + if err != nil { + api.WriteError(w, http.StatusBadRequest, "Chirp id must be a valid uuid for an existing chirp") + return + } + + existingChirp, err := s.db.GetChirpById(r.Context(), validChirpId) + + if err != nil { + api.WriteError(w, http.StatusNotFound, "Chirp id must be a valid uuid for an existing chirp") + return + } + + if existingChirp.UserID != requestUserId { + api.WriteError(w, http.StatusForbidden, "You can not delete a chirp that does not belong to you") + return + } + + if err := s.db.DeleteChirpById(r.Context(), existingChirp.ID); err != nil { + api.WriteError(w, http.StatusInternalServerError, "Unable to delete chirp") + return + } + + api.WriteJSON(w, http.StatusNoContent, nil) +} diff --git a/internal/server/handlers_users.go b/internal/server/handlers_users.go new file mode 100644 index 0000000..448c133 --- /dev/null +++ b/internal/server/handlers_users.go @@ -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)) +} diff --git a/internal/server/handlers_webhooks.go b/internal/server/handlers_webhooks.go new file mode 100644 index 0000000..34ddc45 --- /dev/null +++ b/internal/server/handlers_webhooks.go @@ -0,0 +1,89 @@ +package server + +import ( + "encoding/json" + "net/http" + + "github.com/StevanFreeborn/chirpy/internal/api" + "github.com/StevanFreeborn/chirpy/internal/auth" + "github.com/google/uuid" +) + +type userUpgradeData struct { + UserId string `json:"user_id"` +} + +type webhookEvent struct { + Event string `json:"event"` + Data any `json:"-"` +} + +// UnmarshalJSON captures the raw event data so it can be decoded into the +// concrete type for the event name. +func (p *webhookEvent) UnmarshalJSON(b []byte) error { + type Alias webhookEvent + + aux := &struct { + *Alias + RawData json.RawMessage `json:"data"` + }{ + Alias: (*Alias)(p), + } + + if err := json.Unmarshal(b, aux); err != nil { + return err + } + + switch p.Event { + case "user.upgraded": + var data userUpgradeData + + if err := json.Unmarshal(aux.RawData, &data); err != nil { + return err + } + + p.Data = data + default: + } + + return nil +} + +// HandleWebhooks processes Polka webhook events, e.g. upgrading a user to +// Chirpy Red. +func (s *Server) HandleWebhooks(w http.ResponseWriter, r *http.Request) { + apiKey, err := auth.GetAPIKey(r.Header) + + if err != nil || apiKey != s.polkaKey { + api.WriteError(w, http.StatusUnauthorized, "You are not authorized to perform this action") + return + } + + event, err := api.DecodeJSON[webhookEvent](r) + + if err != nil { + api.WriteError(w, http.StatusInternalServerError, "Unable to deserialize webhook event") + return + } + + switch v := event.Data.(type) { + case userUpgradeData: + validUserId, err := uuid.Parse(v.UserId) + + if err != nil { + api.WriteError(w, http.StatusNotFound, "Unable to upgrade user") + return + } + + _, err = s.db.UpgradeUser(r.Context(), validUserId) + + if err != nil { + api.WriteError(w, http.StatusNotFound, "Unable to upgrade user") + return + } + + api.WriteJSON(w, http.StatusNoContent, nil) + default: + api.WriteJSON(w, http.StatusNoContent, nil) + } +} diff --git a/internal/server/profanity.go b/internal/server/profanity.go new file mode 100644 index 0000000..de70a1a --- /dev/null +++ b/internal/server/profanity.go @@ -0,0 +1,22 @@ +package server + +import ( + "slices" + "strings" +) + +var profaneWords = []string{"kerfuffle", "sharbert", "fornax"} + +// cleanProfanity replaces profane words with asterisks, case-insensitively, +// matching on whole words only. +func cleanProfanity(body string) string { + words := strings.Split(body, " ") + + for i, word := range words { + if slices.Contains(profaneWords, strings.ToLower(word)) { + words[i] = "****" + } + } + + return strings.Join(words, " ") +} diff --git a/internal/server/profanity_test.go b/internal/server/profanity_test.go new file mode 100644 index 0000000..8efa646 --- /dev/null +++ b/internal/server/profanity_test.go @@ -0,0 +1,47 @@ +package server + +import "testing" + +func TestCleanProfanity(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no profanity", + input: "hello world", + expected: "hello world", + }, + { + name: "case insensitive", + input: "That was a KERFUFFLE", + expected: "That was a ****", + }, + { + name: "not a substring match", + input: "kerfuffling away", + expected: "kerfuffling away", + }, + { + name: "multiple words", + input: "sharbert fornax", + expected: "**** ****", + }, + { + name: "empty body", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cleanProfanity(tt.input) + + if got != tt.expected { + t.Errorf("cleanProfanity(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..a224cea --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,56 @@ +// Package server contains the HTTP handlers for the Chirpy API. +package server + +import ( + "net/http" + "sync/atomic" + + "github.com/StevanFreeborn/chirpy/internal/database" +) + +// Server holds the dependencies shared by all HTTP handlers. +type Server struct { + fileServerHits atomic.Int32 + db *database.Queries + platform string + jwtSecret []byte + polkaKey string + indexHTML []byte +} + +// New creates a Server with its dependencies wired in. +func New(db *database.Queries, platform string, jwtSecret []byte, polkaKey string, indexHTML []byte) *Server { + return &Server{ + db: db, + platform: platform, + jwtSecret: jwtSecret, + polkaKey: polkaKey, + indexHTML: indexHTML, + } +} + +// Handler returns the fully configured HTTP handler for the application. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + mux.Handle("GET /app/assets/", s.handleFiles("/app/assets/")) + mux.HandleFunc("GET /app/{$}", s.HandleIndex) + mux.HandleFunc("GET /admin/metrics", s.HandleMetrics) + mux.HandleFunc("POST /admin/reset", s.HandleReset) + + mux.HandleFunc("GET /api/healthz", s.HandleHealthChecks) + + mux.HandleFunc("POST /api/login", s.HandleLogin) + mux.HandleFunc("POST /api/refresh", s.HandleRefresh) + mux.HandleFunc("POST /api/revoke", s.HandleRevoke) + mux.HandleFunc("POST /api/users", s.HandleCreateUser) + mux.HandleFunc("PUT /api/users", s.HandleUpdateUser) + mux.HandleFunc("POST /api/polka/webhooks", s.HandleWebhooks) + + mux.HandleFunc("GET /api/chirps", s.HandleGetChirps) + mux.HandleFunc("GET /api/chirps/{id}", s.HandleGetChirp) + mux.HandleFunc("POST /api/chirps", s.HandleCreateChirp) + mux.HandleFunc("DELETE /api/chirps/{id}", s.HandleDeleteChirp) + + return mux +} diff --git a/main.go b/main.go index a8adc96..98748e4 100644 --- a/main.go +++ b/main.go @@ -3,800 +3,28 @@ package main import ( "context" "database/sql" - "encoding/json" + _ "embed" "errors" - "fmt" "log" "net/http" "os" "os/signal" - "slices" "strings" - "sync/atomic" "syscall" "time" - "github.com/StevanFreeborn/chirpy/internal/auth" "github.com/StevanFreeborn/chirpy/internal/database" - "github.com/google/uuid" + "github.com/StevanFreeborn/chirpy/internal/server" "github.com/joho/godotenv" _ "github.com/lib/pq" ) -type server struct { - fileServerHits atomic.Int32 - database *database.Queries - platform string - jwtSecret []byte - polkaKey string -} - -func writeJsonResponse(w http.ResponseWriter, statusCode int, response any) { - if response == nil { - w.WriteHeader(statusCode) - return - } - - data, err := json.Marshal(response) - - if err != nil { - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - - err := apiError{ - Err: "Failed to encode JSON response", - } - - errData, _ := json.Marshal(err) - - w.Write(errData) - return - } - - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(statusCode) - w.Write(data) -} - -func decodeJsonRequest[T any](r *http.Request) (*T, error) { - defer r.Body.Close() - var data *T - decoder := json.NewDecoder(r.Body) - err := decoder.Decode(&data) - - if err != nil { - return nil, apiError{ - Err: "Failed to deserialize request", - } - } - - return data, err -} - -func (s *server) HandleFiles(prefix string) http.Handler { - fileServer := http.StripPrefix(prefix, http.FileServer(http.Dir("."))) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - s.fileServerHits.Add(1) - fileServer.ServeHTTP(w, r) - }) -} - -func (s *server) HandleHealthChecks(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) -} - -func (s *server) HandleReset(w http.ResponseWriter, r *http.Request) { - if s.platform != "dev" { - w.WriteHeader(http.StatusForbidden) - return - } - - s.fileServerHits.Store(0) - - s.database.DeleteAllUsers(r.Context()) - - w.Header().Add("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) -} - -func (s *server) HandleMetrics(w http.ResponseWriter, r *http.Request) { - hits := s.fileServerHits.Load() - - w.Header().Add("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - - template := ` - - -Chirpy has been visited %d times!
- - - ` - - fmt.Fprintf(w, template, hits) -} - -type apiError struct { - Err string `json:"error"` -} - -func (e apiError) Error() string { - return e.Err -} - -type createChirpRequest struct { - Body string `json:"body"` - UserId string `json:"user_id"` -} - -type chirpResponse struct { - Id string `json:"id"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Body string `json:"body"` - UserId string `json:"user_id"` -} - -func (s *server) HandleCreateChirp(w http.ResponseWriter, r *http.Request) { - blacklist := []string{ - "kerfuffle", - "sharbert", - "fornax", - } - - bearerToken, err := auth.GetBearerToken(r.Header) - - unauthorizedError := apiError{ - Err: "You are not authorized to perform this action", - } - - if err != nil { - writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) - return - } - - requestUserId, err := auth.ValidateJWT(bearerToken, s.jwtSecret) - - if err != nil { - writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) - return - } - - createChirpRequest, err := decodeJsonRequest[createChirpRequest](r) - - if err != nil { - writeJsonResponse(w, http.StatusBadRequest, err) - return - } - - if len(createChirpRequest.Body) > 140 { - writeJsonResponse(w, http.StatusBadRequest, apiError{ - Err: "Chirp is too long", - }) - return - } - - words := strings.Split(createChirpRequest.Body, " ") - sanitized := []string{} - - for _, word := range words { - if slices.Contains(blacklist, strings.ToLower(word)) { - sanitized = append(sanitized, "****") - continue - } - - sanitized = append(sanitized, word) - } - - cleanedBody := strings.Join(sanitized, " ") - - createChirpParams := database.CreateChirpParams{ - Body: cleanedBody, - UserID: requestUserId, - } - - createdChirp, err := s.database.CreateChirp(r.Context(), createChirpParams) - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, apiError{ - Err: "Failed to create chirp. 🤷🏻♂️", - }) - return - } - - writeJsonResponse(w, http.StatusCreated, chirpResponse{ - Id: createdChirp.ID.String(), - CreatedAt: createdChirp.CreatedAt.Format(time.RFC3339), - UpdatedAt: createdChirp.UpdatedAt.Format(time.RFC3339), - Body: createdChirp.Body, - UserId: createdChirp.UserID.String(), - }) -} - -type createUserRequest struct { - Email string `json:"email"` - Password string `json:"password"` -} - -type createUserResponse 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"` -} - -func (s *server) HandleCreateUser(w http.ResponseWriter, r *http.Request) { - createUserRequest, err := decodeJsonRequest[createUserRequest](r) - - if err != nil { - writeJsonResponse(w, http.StatusBadRequest, err) - return - } - - trimmedEmail := strings.TrimSpace(createUserRequest.Email) - - if strings.TrimSpace(trimmedEmail) == "" { - writeJsonResponse(w, http.StatusBadRequest, apiError{ - Err: "email is required. must be valid email address.", - }) - return - } - - trimmedPassword := strings.TrimSpace(createUserRequest.Password) - - if strings.TrimSpace(trimmedEmail) == "" { - writeJsonResponse(w, http.StatusBadRequest, apiError{ - Err: "password is required. must be non-empty string.", - }) - return - } - - hashed_password, err := auth.HashPassword(trimmedPassword) - - createUserError := apiError{ - Err: "Uh oh we were unable to create a new user", - } - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, createUserError) - return - } - - createUserParams := database.CreateUserParams{ - Email: trimmedEmail, - HashedPassword: hashed_password, - } - - createdUser, err := s.database.CreateUser(r.Context(), createUserParams) - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, createUserError) - return - } - - writeJsonResponse(w, http.StatusCreated, createUserResponse{ - Id: createdUser.ID.String(), - CreatedAt: createdUser.CreatedAt.Format(time.RFC3339), - UpdatedAt: createdUser.UpdatedAt.Format(time.RFC3339), - Email: createdUser.Email, - IsChirpyRed: createdUser.IsChirpyRed, - }) -} - -const ( - Ascending = "asc" - Descending = "desc" -) - -func (s *server) HandleGetChirps(w http.ResponseWriter, r *http.Request) { - authorId := r.URL.Query().Get("author_id") - sortDir := r.URL.Query().Get("sort") - - hasAuthorId := strings.TrimSpace(authorId) != "" - hasSort := strings.TrimSpace(sortDir) != "" - - validAuthorId, err := uuid.Parse(authorId) - - if hasAuthorId && err != nil { - writeJsonResponse(w, http.StatusBadRequest, apiError{ - Err: "Author id must be a valid uuid", - }) - return - } - - if hasSort && sortDir != Ascending && sortDir != Descending { - writeJsonResponse(w, http.StatusBadRequest, apiError{ - Err: fmt.Sprintf("Sort direction must be either '%s' or '%s'", Ascending, Descending), - }) - return - } - - if !hasSort { - sortDir = "asc" - } - - var existingChirps []database.Chirp - - if hasAuthorId { - existingChirps, err = s.database.GetChirpsByAuthor(r.Context(), validAuthorId) - } else { - existingChirps, err = s.database.GetAllChirps(r.Context()) - } - - slices.SortFunc(existingChirps, func(a, b database.Chirp) int { - if sortDir == Descending { - return b.CreatedAt.Compare(a.CreatedAt) - } - - return a.CreatedAt.Compare(b.CreatedAt) - }) - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, apiError{ - Err: "Unable to retrieve chirps", - }) - return - } - - chirps := []chirpResponse{} - - for _, c := range existingChirps { - chirps = append(chirps, chirpResponse{ - Id: c.ID.String(), - CreatedAt: c.CreatedAt.Format(time.RFC3339), - UpdatedAt: c.UpdatedAt.Format(time.RFC3339), - Body: c.Body, - UserId: c.UserID.String(), - }) - } - - writeJsonResponse(w, http.StatusOK, chirps) -} - -func (s *server) HandleGetChirp(w http.ResponseWriter, r *http.Request) { - chirpId := r.PathValue("id") - validChirpId, err := uuid.Parse(chirpId) - - if err != nil { - writeJsonResponse(w, http.StatusBadRequest, apiError{ - Err: "Chirp id is not valid. id must be valid UUID.", - }) - return - } - - existingChirp, err := s.database.GetChirpById(r.Context(), validChirpId) - - if err != nil { - writeJsonResponse(w, http.StatusNotFound, apiError{ - Err: "No chirp with given id found.", - }) - return - } - - writeJsonResponse(w, http.StatusOK, chirpResponse{ - Id: existingChirp.ID.String(), - CreatedAt: existingChirp.CreatedAt.Format(time.RFC3339), - UpdatedAt: existingChirp.UpdatedAt.Format(time.RFC3339), - Body: existingChirp.Body, - UserId: existingChirp.UserID.String(), - }) -} - -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 apiError{ - Err: "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"` -} - -func (s *server) HandleLogin(w http.ResponseWriter, r *http.Request) { - loginRequest, err := decodeJsonRequest[loginRequest](r) - - err = loginRequest.Validate() - - if err != nil { - writeJsonResponse(w, http.StatusBadRequest, err) - return - } - - existingUser, err := s.database.GetUserByEmail(r.Context(), loginRequest.Email) - - invalidLoginError := apiError{ - Err: "Invalid login request", - } - - if err != nil { - writeJsonResponse(w, http.StatusUnauthorized, invalidLoginError) - return - } - - isCorrectPassword, err := auth.CheckPasswordHash(loginRequest.Password, existingUser.HashedPassword) - - if err != nil || isCorrectPassword == false { - writeJsonResponse(w, http.StatusUnauthorized, invalidLoginError) - return - } - - SECONDS_PER_HOUR := 3600 - expiresInDuration := time.Duration(SECONDS_PER_HOUR) * time.Second - - accessToken, err := auth.MakeJWT(existingUser.ID, s.jwtSecret, expiresInDuration) - - loginFailedError := apiError{ - Err: "Login failed", - } - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, loginFailedError) - 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.database.CreateRefreshToken(r.Context(), createRefreshTokenParams) - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, loginFailedError) - return - } - - writeJsonResponse(w, http.StatusOK, loginResponse{ - Id: existingUser.ID.String(), - CreatedAt: existingUser.CreatedAt.Format(time.RFC3339), - UpdatedAt: existingUser.UpdatedAt.Format(time.RFC3339), - Email: existingUser.Email, - IsChirpyRed: existingUser.IsChirpyRed, - Token: accessToken, - RefreshToken: createdRefreshToken.Token, - }) -} - -type refreshTokenResponse struct { - Token string `json:"token"` -} - -func (s *server) HandleRefresh(w http.ResponseWriter, r *http.Request) { - refreshToken, err := auth.GetBearerToken(r.Header) - - unauthorizedError := apiError{ - Err: "Unable to refresh token", - } - - if err != nil { - writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) - return - } - - existingRefreshToken, err := s.database.GetRefreshTokenByToken(r.Context(), refreshToken) - - if err != nil { - writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) - return - } - - if existingRefreshToken.ExpiresAt.Before(time.Now()) || existingRefreshToken.RevokedAt.Valid { - writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) - return - } - - SECONDS_PER_HOUR := 3600 - expiresInDuration := time.Duration(SECONDS_PER_HOUR) * time.Second - - accessToken, err := auth.MakeJWT(existingRefreshToken.UserID, s.jwtSecret, expiresInDuration) - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, apiError{ - Err: "Failed to refresh token", - }) - return - } - - // TODO: We should rotate the refresh token - - writeJsonResponse(w, http.StatusOK, refreshTokenResponse{ - Token: accessToken, - }) -} - -func (s *server) HandleRevoke(w http.ResponseWriter, r *http.Request) { - refreshToken, err := auth.GetBearerToken(r.Header) - - if err != nil { - writeJsonResponse(w, http.StatusBadRequest, apiError{ - Err: "No refresh token present in request", - }) - return - } - - existingRefreshToken, err := s.database.GetRefreshTokenByToken(r.Context(), refreshToken) - - if err != nil { - writeJsonResponse(w, http.StatusNotFound, apiError{ - Err: "Unable to revoke token", - }) - return - } - - err = s.database.RevokeRefreshToken(r.Context(), existingRefreshToken.Token) - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, apiError{ - Err: "Unable to revoke token", - }) - return - } - - writeJsonResponse(w, http.StatusNoContent, nil) -} - -type updateUserRequest struct { - Email string `json:"email"` - Password string `json:"password"` -} - -type updateUserResponse 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"` -} - -func (r *updateUserRequest) Validate() error { - if strings.TrimSpace(r.Email) == "" || strings.TrimSpace(r.Password) == "" { - return apiError{ - Err: "Email and password must be non-empty string", - } - } - - return nil -} - -func (s *server) HandleUpdateUser(w http.ResponseWriter, r *http.Request) { - accessToken, err := auth.GetBearerToken(r.Header) - - if err != nil { - writeJsonResponse(w, http.StatusUnauthorized, apiError{ - Err: "You are not authorized to perform this action", - }) - return - } - - requestUserId, err := auth.ValidateJWT(accessToken, s.jwtSecret) - - if err != nil { - writeJsonResponse(w, http.StatusUnauthorized, apiError{ - Err: "You are not authorized to perform this action", - }) - return - } - - updateUserRequest, err := decodeJsonRequest[updateUserRequest](r) - - if err != nil { - writeJsonResponse(w, http.StatusBadRequest, err) - return - } - - err = updateUserRequest.Validate() - - if err != nil { - writeJsonResponse(w, http.StatusBadRequest, err) - return - } - - updatedHashedPassword, err := auth.HashPassword(updateUserRequest.Password) - - updateError := apiError{ - Err: "Unable to perform update", - } - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, updateError) - return - } - - updateUserParams := database.UpdateUserParams{ - ID: requestUserId, - Email: updateUserRequest.Email, - HashedPassword: updatedHashedPassword, - } - - updatedUser, err := s.database.UpdateUser(r.Context(), updateUserParams) - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, updateError) - return - } - - writeJsonResponse(w, http.StatusOK, updateUserResponse{ - Id: updatedUser.ID.String(), - CreatedAt: updatedUser.CreatedAt.Format(time.RFC3339), - UpdatedAt: updatedUser.UpdatedAt.Format(time.RFC3339), - Email: updatedUser.Email, - IsChirpyRed: updatedUser.IsChirpyRed, - }) -} - -func (s *server) HandleDeleteChirp(w http.ResponseWriter, r *http.Request) { - accessToken, err := auth.GetBearerToken(r.Header) - - unauthorizedError := apiError{ - Err: "You are not authorized to perform this action", - } - - if err != nil { - writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) - return - } - - requestUserId, err := auth.ValidateJWT(accessToken, s.jwtSecret) - - if err != nil { - writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) - return - } - - chirpId := r.PathValue("id") - validChirpId, err := uuid.Parse(chirpId) - - invalidChirpIdError := apiError{ - Err: "Chirp id must be a valid uuid for an existing chirp", - } - - if err != nil { - writeJsonResponse(w, http.StatusBadRequest, invalidChirpIdError) - return - } - - existingChirp, err := s.database.GetChirpById(r.Context(), validChirpId) - - if err != nil { - writeJsonResponse(w, http.StatusNotFound, invalidChirpIdError) - return - } - - if existingChirp.UserID != requestUserId { - writeJsonResponse(w, http.StatusForbidden, apiError{ - Err: "You can not delete a chirp that does not belong to you", - }) - return - } - - err = s.database.DeleteChirpById(r.Context(), existingChirp.ID) - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, apiError{ - Err: "Yo bro we couldn't delete that shit", - }) - return - } - - writeJsonResponse(w, http.StatusNoContent, nil) -} - -type userUpgradeData struct { - UserId string `json:"user_id"` -} - -type webhookEvent struct { - Event string `json:"event"` - Data any `json:"-"` -} - -func (p *webhookEvent) UnmarshalJSON(b []byte) error { - type Alias webhookEvent - - aux := &struct { - *Alias - RawData json.RawMessage `json:"data"` - }{ - Alias: (*Alias)(p), - } - - if err := json.Unmarshal(b, aux); err != nil { - return err - } - - switch p.Event { - case "user.upgraded": - var data userUpgradeData - - if err := json.Unmarshal(aux.RawData, &data); err != nil { - return err - } - - p.Data = data - default: - } - - return nil -} - -func (s *server) HandleWebhooks(w http.ResponseWriter, r *http.Request) { - apiKey, err := auth.GetAPIKey(r.Header) - - unauthorizedError := apiError{ - Err: "You are not authorized to perform this action", - } - - if err != nil || apiKey != s.polkaKey { - writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) - return - } - - event, err := decodeJsonRequest[webhookEvent](r) - - if err != nil { - writeJsonResponse(w, http.StatusInternalServerError, apiError{ - Err: "Unable to deserialize webhook event", - }) - return - } - - switch v := event.Data.(type) { - case userUpgradeData: - validUserId, err := uuid.Parse(v.UserId) - - userNotFoundError := apiError{ - Err: "Unable to upgrade user", - } - - if err != nil { - writeJsonResponse(w, http.StatusNotFound, userNotFoundError) - return - } - - _, err = s.database.UpgradeUser(r.Context(), validUserId) - - if err != nil { - writeJsonResponse(w, http.StatusNotFound, userNotFoundError) - return - } - - writeJsonResponse(w, http.StatusNoContent, nil) - default: - writeJsonResponse(w, http.StatusNoContent, nil) - } -} +//go:embed index.html +var indexHTML []byte func main() { - err := godotenv.Load() - - if err != nil { - log.Fatalf("Failed to load environment variables") - os.Exit(1) + if err := godotenv.Load(); err != nil { + log.Fatal("Failed to load environment variables") } dbURL := os.Getenv("DB_URL") @@ -805,64 +33,36 @@ func main() { polkaKey := os.Getenv("POLKA_KEY") if strings.TrimSpace(jwtSecret) == "" { - log.Fatalf("Failed to retrieve JWT_SECRET") - os.Exit(1) + log.Fatal("Failed to retrieve JWT_SECRET") } if strings.TrimSpace(polkaKey) == "" { - log.Fatalf("Failed to retrieve POLKA_KEY") - os.Exit(1) + log.Fatal("Failed to retrieve POLKA_KEY") } db, err := sql.Open("postgres", dbURL) if err != nil { log.Fatalf("Failed to connect to database: %v", err) - os.Exit(1) } - server := &server{ - database: database.New(db), - platform: platform, - jwtSecret: []byte(jwtSecret), - polkaKey: polkaKey, + if err := db.Ping(); err != nil { + log.Fatalf("Failed to ping database: %v", err) } - mux := http.NewServeMux() - - appRoot := "/app/" - - mux.Handle(appRoot, server.HandleFiles(appRoot)) - mux.HandleFunc("GET /admin/metrics", server.HandleMetrics) - mux.HandleFunc("POST /admin/reset", server.HandleReset) - - mux.HandleFunc("GET /api/healthz", server.HandleHealthChecks) - - mux.HandleFunc("POST /api/login", server.HandleLogin) - mux.HandleFunc("POST /api/refresh", server.HandleRefresh) - mux.HandleFunc("POST /api/revoke", server.HandleRevoke) - mux.HandleFunc("POST /api/users", server.HandleCreateUser) - mux.HandleFunc("PUT /api/users", server.HandleUpdateUser) - mux.HandleFunc("POST /api/polka/webhooks", server.HandleWebhooks) - - mux.HandleFunc("GET /api/chirps", server.HandleGetChirps) - mux.HandleFunc("GET /api/chirps/{id}", server.HandleGetChirp) - mux.HandleFunc("POST /api/chirps", server.HandleCreateChirp) - mux.HandleFunc("DELETE /api/chirps/{id}", server.HandleDeleteChirp) + srv := server.New(database.New(db), platform, []byte(jwtSecret), polkaKey, indexHTML) port := ":8080" httpServer := http.Server{ - Handler: mux, + Handler: srv.Handler(), Addr: port, } go func() { log.Printf("Server started and listening on %s\n", port) - err := httpServer.ListenAndServe() - - if !errors.Is(err, http.ErrServerClosed) { + if err := httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { log.Fatalf("HTTP Server error: %v", err) } @@ -874,7 +74,6 @@ func main() { <-sigChan shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) - defer shutdownRelease() if err := httpServer.Shutdown(shutdownCtx); err != nil { diff --git a/run.sh b/run.sh index e59f582..e3df9e6 100755 --- a/run.sh +++ b/run.sh @@ -1,2 +1,7 @@ -go build -o ./bin +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +go build -o ./bin/chirpy . ./bin/chirpy diff --git a/sql/queries/users.sql b/sql/queries/users.sql index 2535321..a54d263 100644 --- a/sql/queries/users.sql +++ b/sql/queries/users.sql @@ -31,5 +31,3 @@ UPDATE users SET is_chirpy_red = TRUE WHERE id = $1 RETURNING *; - -