From d34c7b5822a4e44f550f1e117a3b62f743bee9b7 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Sat, 15 Aug 2026 20:18:21 -0500 Subject: [PATCH] feat: implemented handling webhooks --- internal/auth/auth.go | 12 +++ internal/database/models.go | 1 + internal/database/users.sql.go | 33 ++++++- main.go | 162 +++++++++++++++++++++++++++----- sql/queries/users.sql | 12 ++- sql/schema/005_addchirpyred.sql | 7 ++ 6 files changed, 198 insertions(+), 29 deletions(-) create mode 100644 sql/schema/005_addchirpyred.sql diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8a008ef..1aad80a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -70,6 +70,18 @@ func GetBearerToken(headers http.Header) (string, error) { return token, nil } +func GetAPIKey(headers http.Header) (string, error) { + authorizationHeader := headers.Get("Authorization") + + if strings.TrimSpace(authorizationHeader) == "" { + return "", errors.New("Missing Authorization header") + } + + apiKey := strings.TrimPrefix(authorizationHeader, "ApiKey ") + + return apiKey, nil +} + func MakeRefreshToken() string { bytes := make([]byte, 32) rand.Read(bytes) diff --git a/internal/database/models.go b/internal/database/models.go index e48f9f8..bd96733 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -34,4 +34,5 @@ type User struct { UpdatedAt time.Time Email string HashedPassword string + IsChirpyRed bool } diff --git a/internal/database/users.sql.go b/internal/database/users.sql.go index affd674..0896628 100644 --- a/internal/database/users.sql.go +++ b/internal/database/users.sql.go @@ -20,7 +20,7 @@ VALUES ( $1, $2 ) -RETURNING id, created_at, updated_at, email, hashed_password +RETURNING id, created_at, updated_at, email, hashed_password, is_chirpy_red ` type CreateUserParams struct { @@ -37,6 +37,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.UpdatedAt, &i.Email, &i.HashedPassword, + &i.IsChirpyRed, ) return i, err } @@ -51,7 +52,7 @@ func (q *Queries) DeleteAllUsers(ctx context.Context) error { } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, created_at, updated_at, email, hashed_password FROM users +SELECT id, created_at, updated_at, email, hashed_password, is_chirpy_red FROM users WHERE email = $1 ` @@ -64,12 +65,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error &i.UpdatedAt, &i.Email, &i.HashedPassword, + &i.IsChirpyRed, ) return i, err } const getUserById = `-- name: GetUserById :one -SELECT id, created_at, updated_at, email, hashed_password FROM users +SELECT id, created_at, updated_at, email, hashed_password, is_chirpy_red FROM users WHERE id = $1 ` @@ -82,6 +84,7 @@ func (q *Queries) GetUserById(ctx context.Context, id uuid.UUID) (User, error) { &i.UpdatedAt, &i.Email, &i.HashedPassword, + &i.IsChirpyRed, ) return i, err } @@ -90,7 +93,7 @@ const updateUser = `-- name: UpdateUser :one UPDATE users SET email = $2, hashed_password = $3, updated_at = NOW() WHERE id = $1 -RETURNING id, created_at, updated_at, email, hashed_password +RETURNING id, created_at, updated_at, email, hashed_password, is_chirpy_red ` type UpdateUserParams struct { @@ -108,6 +111,28 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, e &i.UpdatedAt, &i.Email, &i.HashedPassword, + &i.IsChirpyRed, + ) + return i, err +} + +const upgradeUser = `-- name: UpgradeUser :one +UPDATE users +SET is_chirpy_red = TRUE +WHERE id = $1 +RETURNING id, created_at, updated_at, email, hashed_password, is_chirpy_red +` + +func (q *Queries) UpgradeUser(ctx context.Context, id uuid.UUID) (User, error) { + row := q.db.QueryRowContext(ctx, upgradeUser, id) + var i User + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Email, + &i.HashedPassword, + &i.IsChirpyRed, ) return i, err } diff --git a/main.go b/main.go index d073cf7..5b29abf 100644 --- a/main.go +++ b/main.go @@ -28,17 +28,34 @@ type server struct { database *database.Queries platform string jwtSecret []byte + polkaKey string } func writeJsonResponse(w http.ResponseWriter, statusCode int, response any) { - w.WriteHeader(statusCode) - w.Header().Add("Content-Type", "application/json") - - encoder := json.NewEncoder(w) - - if err := encoder.Encode(response); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + 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) { @@ -207,10 +224,11 @@ type createUserRequest struct { } type createUserResponse struct { - Id string `json:"id"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Email string `json:"email"` + 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) { @@ -263,10 +281,11 @@ func (s *server) HandleCreateUser(w http.ResponseWriter, r *http.Request) { } writeJsonResponse(w, http.StatusCreated, createUserResponse{ - Id: createdUser.ID.String(), - CreatedAt: createdUser.CreatedAt.Format(time.RFC3339), - UpdatedAt: createdUser.UpdatedAt.Format(time.RFC3339), - Email: createdUser.Email, + Id: createdUser.ID.String(), + CreatedAt: createdUser.CreatedAt.Format(time.RFC3339), + UpdatedAt: createdUser.UpdatedAt.Format(time.RFC3339), + Email: createdUser.Email, + IsChirpyRed: createdUser.IsChirpyRed, }) } @@ -344,6 +363,7 @@ type loginResponse struct { 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"` } @@ -411,6 +431,7 @@ func (s *server) HandleLogin(w http.ResponseWriter, r *http.Request) { CreatedAt: existingUser.CreatedAt.Format(time.RFC3339), UpdatedAt: existingUser.UpdatedAt.Format(time.RFC3339), Email: existingUser.Email, + IsChirpyRed: existingUser.IsChirpyRed, Token: accessToken, RefreshToken: createdRefreshToken.Token, }) @@ -500,10 +521,11 @@ type updateUserRequest struct { } type updateUserResponse struct { - Id string `json:"id"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Email string `json:"email"` + 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 { @@ -574,10 +596,11 @@ func (s *server) HandleUpdateUser(w http.ResponseWriter, r *http.Request) { } writeJsonResponse(w, http.StatusOK, updateUserResponse{ - Id: updatedUser.ID.String(), - CreatedAt: updatedUser.CreatedAt.Format(time.RFC3339), - UpdatedAt: updatedUser.UpdatedAt.Format(time.RFC3339), - Email: updatedUser.Email, + Id: updatedUser.ID.String(), + CreatedAt: updatedUser.CreatedAt.Format(time.RFC3339), + UpdatedAt: updatedUser.UpdatedAt.Format(time.RFC3339), + Email: updatedUser.Email, + IsChirpyRed: updatedUser.IsChirpyRed, }) } @@ -638,6 +661,91 @@ func (s *server) HandleDeleteChirp(w http.ResponseWriter, r *http.Request) { 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) + } +} + func main() { err := godotenv.Load() @@ -649,12 +757,18 @@ func main() { dbURL := os.Getenv("DB_URL") platform := os.Getenv("PLATFORM") jwtSecret := os.Getenv("JWT_SECRET") + polkaKey := os.Getenv("POLKA_KEY") if strings.TrimSpace(jwtSecret) == "" { log.Fatalf("Failed to retrieve JWT_SECRET") os.Exit(1) } + if strings.TrimSpace(polkaKey) == "" { + log.Fatalf("Failed to retrieve POLKA_KEY") + os.Exit(1) + } + db, err := sql.Open("postgres", dbURL) if err != nil { @@ -666,6 +780,7 @@ func main() { database: database.New(db), platform: platform, jwtSecret: []byte(jwtSecret), + polkaKey: polkaKey, } mux := http.NewServeMux() @@ -683,6 +798,7 @@ func main() { 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) diff --git a/sql/queries/users.sql b/sql/queries/users.sql index b53b17d..2535321 100644 --- a/sql/queries/users.sql +++ b/sql/queries/users.sql @@ -19,9 +19,17 @@ RETURNING *; DELETE FROM users; -- name: GetUserById :one -SELECT id, created_at, updated_at, email, hashed_password FROM users +SELECT id, created_at, updated_at, email, hashed_password, is_chirpy_red FROM users WHERE id = $1; -- name: GetUserByEmail :one -SELECT id, created_at, updated_at, email, hashed_password FROM users +SELECT id, created_at, updated_at, email, hashed_password, is_chirpy_red FROM users WHERE email = $1; + +-- name: UpgradeUser :one +UPDATE users +SET is_chirpy_red = TRUE +WHERE id = $1 +RETURNING *; + + diff --git a/sql/schema/005_addchirpyred.sql b/sql/schema/005_addchirpyred.sql new file mode 100644 index 0000000..a783e7c --- /dev/null +++ b/sql/schema/005_addchirpyred.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE users +ADD is_chirpy_red BOOLEAN NOT NULL DEFAULT FALSE; + +-- +goose Down +ALTER TABLE users +DROP COLUMN is_chirpy_red;