Compare commits

...
8 Commits
Author SHA1 Message Date
Stevan Freeborn c53ce4a32e docs: fix table formating 2026-08-15 22:10:58 -05:00
Stevan Freeborn 19f5aca484 fix: address /home/stevan/go/bin/golangci-lint run ./... issues 2026-08-15 22:09:59 -05:00
Stevan Freeborn 9eca3c71e2 docs: document auth helpers and expand README
Add godoc comments to the exported auth package helpers. Expand the README
with feature and tech stack overviews, setup instructions, an API reference,
and make targets for development workflows.
2026-08-15 22:07:31 -05:00
Stevan Freeborn 6294910727 chore: add .env.example and ignore generated editor config
Commit the .env.example template and exempt it from the .env ignore rule.
Ignore the .sqls.yml file that sqlc generate emits for editor tooling.
2026-08-15 22:07:23 -05:00
Stevan Freeborn f9d562e14a build: replace run.sh with a Makefile
Provide make targets for building, running, testing, linting, generating
sqlc code, and managing database migrations. Remove the run.sh script.
2026-08-15 22:07:16 -05:00
Stevan Freeborn 1648b4121c chore: tidy module dependencies
Promote directly imported modules out of the indirect require block.
2026-08-15 22:07:08 -05:00
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
Stevan Freeborn aaa913ccab feat: added sorting and retrieving by author to chirps endpoint 2026-08-15 21:35:53 -05:00
22 changed files with 1223 additions and 781 deletions
+4
View File
@@ -0,0 +1,4 @@
DB_URL=postgres://postgres:postgres@localhost:5432/chirpy?sslmode=disable
PLATFORM=dev
JWT_SECRET=replace-with-a-long-random-secret
POLKA_KEY=replace-with-your-polka-api-key
+2
View File
@@ -1,2 +1,4 @@
bin/
*.env*
!.env.example
.sqls.yml
+94
View File
@@ -0,0 +1,94 @@
# Makefile for Chirpy.
BINARY_NAME ?= chirpy
BIN_DIR ?= bin
BINARY := $(BIN_DIR)/$(BINARY_NAME)
# Prefer tools installed into GOBIN, falling back to PATH.
GOBIN_DIR := $(shell go env GOPATH)/bin
GOOSE ?= $(if $(wildcard $(GOBIN_DIR)/goose),$(GOBIN_DIR)/goose,goose)
SQLC ?= $(if $(wildcard $(GOBIN_DIR)/sqlc),$(GOBIN_DIR)/sqlc,sqlc)
GOLANGCI_LINT ?= $(if $(wildcard $(GOBIN_DIR)/golangci-lint),$(GOBIN_DIR)/golangci-lint,golangci-lint)
# Load local configuration from .env when present. Note: values in .env must
# not contain a bare '#' character, as Make would treat it as a comment.
DB_URL ?= postgres://postgres:postgres@localhost:5432/chirpy?sslmode=disable
-include .env
export
GO_SOURCES := $(shell find . -name '*.go' -not -path './bin/*' -not -path './internal/database/*')
.DEFAULT_GOAL := help
.PHONY: help build run dev test test-race vet fmt fmt-check tidy lint \
generate clean migrate-up migrate-down migrate-status migrate-reset \
install-tools check
help: ## Show this help message
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
build: ## Compile the server binary into $(BIN_DIR)
@mkdir -p $(BIN_DIR)
go build -o $(BINARY) .
run: build ## Build and run the server
./$(BINARY)
dev: ## Run the server with the race detector enabled
go run -race .
test: ## Run all tests
go test ./...
test-race: ## Run all tests with the race detector
go test -race ./...
vet: ## Run go vet
go vet ./...
fmt: ## Format all Go source files
gofmt -s -w $(GO_SOURCES)
fmt-check: ## Verify Go sources are formatted
@unformatted="$$(gofmt -l $(GO_SOURCES))"; \
if [ -n "$$unformatted" ]; then \
echo "The following files are not formatted:"; \
echo "$$unformatted"; \
exit 1; \
fi
tidy: ## Tidy module dependencies
go mod tidy
lint: ## Run golangci-lint if installed
@if ! command -v $(GOLANGCI_LINT) >/dev/null 2>&1; then \
echo "golangci-lint is not installed. Run 'make install-tools' to install it."; \
exit 1; \
fi
$(GOLANGCI_LINT) run ./...
generate: ## Regenerate the sqlc database query code
$(SQLC) generate
clean: ## Remove build artifacts
rm -rf $(BIN_DIR)
migrate-up: ## Apply pending database migrations
$(GOOSE) -dir sql/schema postgres "$(DB_URL)" up
migrate-down: ## Roll back the most recent migration
$(GOOSE) -dir sql/schema postgres "$(DB_URL)" down
migrate-status: ## Show the current migration status
$(GOOSE) -dir sql/schema postgres "$(DB_URL)" status
migrate-reset: ## Roll back all migrations and re-apply them
$(GOOSE) -dir sql/schema postgres "$(DB_URL)" down-to 0
$(GOOSE) -dir sql/schema postgres "$(DB_URL)" up
install-tools: ## Install dev tools (goose, sqlc, golangci-lint)
go install github.com/pressly/goose/v3/cmd/goose@latest
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
check: fmt-check vet test ## Run formatting, vet, and test checks
+92 -1
View File
@@ -1,3 +1,94 @@
# Chirpy
This is a project I built as part of [boot.dev](https://boot.dev).
A Twitter-style microblogging API built in Go as part of the [boot.dev](https://boot.dev) back-end course.
## Features
- Create, list, sort, and delete chirps (140-char limit, profanity-filtered)
- User registration and login with password hashing (argon2id)
- JWT access tokens and revocable refresh tokens
- Chirpy Red subscription upgrades via Polka webhooks
- Graceful shutdown and a lightweight admin metrics/reset surface
## Tech stack
- Go 1.26 (standard `net/http` with `http.ServeMux` routing)
- PostgreSQL
- [sqlc](https://sqlc.dev) for type-safe query generation
- [goose](https://pressly.github.io/goose/) for migrations
- [golang-jwt/jwt](https://github.com/golang-jwt/jwt) and [argon2id](https://github.com/alexedwards/argon2id)
## Getting started
### Prerequisites
- Go 1.26+
- PostgreSQL running locally
- `sqlc`, `goose`, and `make` (run `make install-tools` to install the first two)
### Setup
```sh
# 1. Configure environment variables
cp .env.example .env
# fill in DB_URL, PLATFORM, JWT_SECRET, and POLKA_KEY
# 2. Apply database migrations
make migrate-up
# 3. Run the server
make run
```
The server listens on `http://localhost:8080`.
### Development
Run `make help` to see the full list of targets. Highlights:
| Target | Description |
| ----------------------------------------------------------------------- | -------------------------------------- |
| `make run` | Build and run the server |
| `make dev` | Run with the race detector enabled |
| `make test` | Run all tests |
| `make test-race` | Run all tests with the race detector |
| `make check` | Run formatting, vet, and test checks |
| `make fmt` | Format all Go source files |
| `make lint` | Run golangci-lint (if installed) |
| `make build` | Compile the binary into `bin/` |
| `make generate` | Regenerate sqlc query code |
| `make migrate-up` / `migrate-down` / `migrate-status` / `migrate-reset` | Database migrations |
| `make install-tools` | Install goose, sqlc, and golangci-lint |
| `make clean` | Remove build artifacts |
## API reference
| Method | Path | Description |
| ------ | --------------------- | ----------------------------------------------- |
| POST | `/api/users` | Register a user |
| PUT | `/api/users` | Update email/password (Bearer access token) |
| POST | `/api/login` | Log in, receive access + refresh tokens |
| POST | `/api/refresh` | Exchange a refresh token for a new access token |
| POST | `/api/revoke` | Revoke a refresh token |
| GET | `/api/chirps` | List chirps (`?author_id=`, `?sort=asc\|desc`) |
| GET | `/api/chirps/{id}` | Get a single chirp |
| POST | `/api/chirps` | Create a chirp (Bearer access token) |
| DELETE | `/api/chirps/{id}` | Delete a chirp you authored |
| POST | `/api/polka/webhooks` | Polka webhook (`user.upgraded` -> Chirpy Red) |
| GET | `/api/healthz` | Health check |
| GET | `/app/` | Static index page |
| GET | `/admin/metrics` | File server hit counter (dev only) |
| POST | `/admin/reset` | Reset the database (dev only) |
## Regenerating sqlc code
After changing `sql/queries/`, run:
```sh
make generate
```
## License
MIT — see [LICENSE.md](LICENSE.md).
BIN
View File
Binary file not shown.
+8 -5
View File
@@ -3,11 +3,14 @@ module github.com/StevanFreeborn/chirpy
go 1.26.3
require (
github.com/alexedwards/argon2id v1.0.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/alexedwards/argon2id v1.0.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.12.3
)
require (
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/sys v0.13.0 // indirect
)
+58
View File
@@ -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
}
+89
View File
@@ -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())
}
}
+15 -1
View File
@@ -1,3 +1,5 @@
// Package auth provides password hashing, JWT, and token helpers used to
// authenticate and authorize API requests.
package auth
import (
@@ -13,14 +15,18 @@ import (
"github.com/google/uuid"
)
// HashPassword hashes a plaintext password using argon2id.
func HashPassword(password string) (string, error) {
return argon2id.CreateHash(password, argon2id.DefaultParams)
}
// CheckPasswordHash reports whether password matches the given argon2id hash.
func CheckPasswordHash(password string, hash string) (bool, error) {
return argon2id.ComparePasswordAndHash(password, hash)
}
// MakeJWT creates a signed HS256 JWT for the given user that expires after
// expiresIn.
func MakeJWT(userID uuid.UUID, tokenSecret []byte, expiresIn time.Duration) (string, error) {
token := jwt.New(jwt.GetSigningMethod(jwt.SigningMethodHS256.Name))
@@ -34,6 +40,7 @@ func MakeJWT(userID uuid.UUID, tokenSecret []byte, expiresIn time.Duration) (str
return token.SignedString(tokenSecret)
}
// ValidateJWT verifies the token signature and returns the subject's user ID.
func ValidateJWT(tokenString string, tokenSecret []byte) (uuid.UUID, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) {
return tokenSecret, nil
@@ -58,6 +65,7 @@ func ValidateJWT(tokenString string, tokenSecret []byte) (uuid.UUID, error) {
return validUserId, nil
}
// GetBearerToken extracts the "Bearer" token from the Authorization header.
func GetBearerToken(headers http.Header) (string, error) {
authorizationHeader := headers.Get("Authorization")
@@ -70,6 +78,8 @@ func GetBearerToken(headers http.Header) (string, error) {
return token, nil
}
// GetAPIKey extracts the API key from the Authorization header using the
// "ApiKey" scheme.
func GetAPIKey(headers http.Header) (string, error) {
authorizationHeader := headers.Get("Authorization")
@@ -82,8 +92,12 @@ func GetAPIKey(headers http.Header) (string, error) {
return apiKey, nil
}
// MakeRefreshToken generates a cryptographically random, hex-encoded refresh
// token.
func MakeRefreshToken() string {
bytes := make([]byte, 32)
rand.Read(bytes)
if _, err := rand.Read(bytes); err != nil {
panic(err)
}
return hex.EncodeToString(bytes)
}
+8 -1
View File
@@ -13,9 +13,16 @@ func TestJwtCreationAndValidation(t *testing.T) {
secret := []byte("RTCK2UTcOUkiswdrClC6Z3KmmEq/+QicpD9iRx7J0qY=")
jwtString, err := auth.MakeJWT(userId, secret, time.Hour)
if err != nil {
t.Fatalf("failed to create JWT: %v", err)
}
validatedUserId, err := auth.ValidateJWT(jwtString, secret)
if err != nil {
t.Fatalf("failed to validate JWT: %v", err)
}
if userId != validatedUserId {
t.Fatalf("received %s expected %s: %v", validatedUserId, userId, err)
t.Fatalf("received %s expected %s", validatedUserId, userId)
}
}
+35
View File
@@ -101,3 +101,38 @@ func (q *Queries) GetChirpById(ctx context.Context, id uuid.UUID) (Chirp, error)
)
return i, err
}
const getChirpsByAuthor = `-- name: GetChirpsByAuthor :many
SELECT id, created_at, updated_at, body, user_id FROM chirps
WHERE user_id = $1
ORDER BY created_at ASC
`
func (q *Queries) GetChirpsByAuthor(ctx context.Context, userID uuid.UUID) ([]Chirp, error) {
rows, err := q.db.QueryContext(ctx, getChirpsByAuthor, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Chirp
for rows.Next() {
var i Chirp
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Body,
&i.UserID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+74
View File
@@ -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 := `
<html>
<body>
<h1>Welcome, Chirpy Admin</h1>
<p>Chirpy has been visited %d times!</p>
</body>
</html>
`
fmt.Fprintf(w, template, hits)
}
+205
View File
@@ -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)
}
+307
View File
@@ -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))
}
+89
View File
@@ -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)
}
}
+22
View File
@@ -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, " ")
}
+47
View File
@@ -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)
}
})
}
}
+56
View File
@@ -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
}
+13 -769
View File
@@ -3,755 +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 := `
<html>
<body>
<h1>Welcome, Chirpy Admin</h1>
<p>Chirpy has been visited %d times!</p>
</body>
</html>
`
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,
})
}
func (s *server) HandleGetAllChirps(w http.ResponseWriter, r *http.Request) {
existingChirps, err := s.database.GetAllChirps(r.Context())
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")
@@ -760,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.HandleGetAllChirps)
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)
}
@@ -829,7 +74,6 @@ func main() {
<-sigChan
shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownRelease()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
-2
View File
@@ -1,2 +0,0 @@
go build -o ./bin
./bin/chirpy
+5
View File
@@ -19,3 +19,8 @@ WHERE id = $1;
-- name: DeleteChirpById :exec
DELETE FROM chirps WHERE id = $1;
-- name: GetChirpsByAuthor :many
SELECT id, created_at, updated_at, body, user_id FROM chirps
WHERE user_id = $1
ORDER BY created_at ASC;
-2
View File
@@ -31,5 +31,3 @@ UPDATE users
SET is_chirpy_red = TRUE
WHERE id = $1
RETURNING *;