Compare commits

...

10 Commits

Author SHA1 Message Date
0e5c409621 sort chirps 2025-03-13 23:01:18 +02:00
66be0a2b40 webhooks and chirps by user id 2025-03-13 22:47:03 +02:00
bad1d140ee Delete chirps 2025-03-11 23:04:05 +02:00
704393e0e4 Update self profile 2025-03-11 22:57:39 +02:00
1bad21ae45 Refresh Tokens 2025-03-11 22:39:58 +02:00
5fe09f0a2f Auth? 2025-03-11 21:48:05 +02:00
6661fdf89b Passwords! 2025-03-11 19:54:00 +02:00
647edb6d25 renamed things so IDE would not yell at me 2025-03-11 19:34:33 +02:00
bd6583831c getting chirps! 2025-03-11 19:33:53 +02:00
de5f090a9c creating chirps! 2025-03-11 19:08:34 +02:00
37 changed files with 1223 additions and 44 deletions

9
go.mod
View File

@@ -3,7 +3,10 @@ module github.com/rdarius/go-http-server
go 1.23.6 go 1.23.6
require ( require (
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 // indirect github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.10.9
golang.org/x/crypto v0.36.0
) )
require github.com/golang-jwt/jwt/v5 v5.2.1 // indirect

4
go.sum
View File

@@ -1,6 +1,10 @@
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=

107
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,107 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"net/http"
"strings"
"time"
)
type TokenType string
const (
// TokenTypeAccess -
TokenTypeAccess TokenType = "chirpy-access"
)
func HashPassword(password string) (string, error) {
pass, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(pass), err
}
func CheckPasswordHash(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
// MakeJWT -
func MakeJWT(
userID uuid.UUID,
tokenSecret string,
expiresIn time.Duration,
) (string, error) {
signingKey := []byte(tokenSecret)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
Issuer: string(TokenTypeAccess),
IssuedAt: jwt.NewNumericDate(time.Now().UTC()),
ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(expiresIn)),
Subject: userID.String(),
})
return token.SignedString(signingKey)
}
// ValidateJWT -
func ValidateJWT(tokenString, tokenSecret string) (uuid.UUID, error) {
claimsStruct := jwt.RegisteredClaims{}
token, err := jwt.ParseWithClaims(
tokenString,
&claimsStruct,
func(token *jwt.Token) (interface{}, error) { return []byte(tokenSecret), nil },
)
if err != nil {
return uuid.Nil, err
}
userIDString, err := token.Claims.GetSubject()
if err != nil {
return uuid.Nil, err
}
issuer, err := token.Claims.GetIssuer()
if err != nil {
return uuid.Nil, err
}
if issuer != string(TokenTypeAccess) {
return uuid.Nil, errors.New("invalid issuer")
}
id, err := uuid.Parse(userIDString)
if err != nil {
return uuid.Nil, fmt.Errorf("invalid user ID: %w", err)
}
return id, nil
}
func GetBearerToken(headers http.Header) (string, error) {
auth := headers.Get("Authorization")
auth = strings.TrimPrefix(auth, "Bearer ")
if auth == "" {
return "", errors.New("missing Authorization header")
}
return auth, nil
}
func MakeRefreshToken() (string, error) {
key := make([]byte, 32)
_, err := rand.Read(key)
if err != nil {
return "", err
}
token := hex.EncodeToString(key)
return token, nil
}
func GetAPIKey(headers http.Header) (string, error) {
auth := headers.Get("Authorization")
auth = strings.TrimPrefix(auth, "ApiKey ")
if auth == "" {
return "", errors.New("missing Authorization header")
}
return auth, nil
}

110
internal/auth/auth_test.go Normal file
View File

@@ -0,0 +1,110 @@
package auth
import (
"github.com/google/uuid"
"testing"
"time"
)
func TestCheckPasswordHash(t *testing.T) {
// First, we need to create some hashed passwords for testing
password1 := "correctPassword123!"
password2 := "anotherPassword456!"
hash1, _ := HashPassword(password1)
hash2, _ := HashPassword(password2)
tests := []struct {
name string
password string
hash string
wantErr bool
}{
{
name: "Correct password",
password: password1,
hash: hash1,
wantErr: false,
},
{
name: "Incorrect password",
password: "wrongPassword",
hash: hash1,
wantErr: true,
},
{
name: "Password doesn't match different hash",
password: password1,
hash: hash2,
wantErr: true,
},
{
name: "Empty password",
password: "",
hash: hash1,
wantErr: true,
},
{
name: "Invalid hash",
password: password1,
hash: "invalidhash",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CheckPasswordHash(tt.password, tt.hash)
if (err != nil) != tt.wantErr {
t.Errorf("CheckPasswordHash() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestValidateJWT(t *testing.T) {
userID := uuid.New()
validToken, _ := MakeJWT(userID, "secret", time.Hour)
tests := []struct {
name string
tokenString string
tokenSecret string
wantUserID uuid.UUID
wantErr bool
}{
{
name: "Valid token",
tokenString: validToken,
tokenSecret: "secret",
wantUserID: userID,
wantErr: false,
},
{
name: "Invalid token",
tokenString: "invalid.token.string",
tokenSecret: "secret",
wantUserID: uuid.Nil,
wantErr: true,
},
{
name: "Wrong secret",
tokenString: validToken,
tokenSecret: "wrong_secret",
wantUserID: uuid.Nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotUserID, err := ValidateJWT(tt.tokenString, tt.tokenSecret)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateJWT() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotUserID != tt.wantUserID {
t.Errorf("ValidateJWT() gotUserID = %v, want %v", gotUserID, tt.wantUserID)
}
})
}
}

32
internal/dataMaps/maps.go Normal file
View File

@@ -0,0 +1,32 @@
package dataMaps
import (
"github.com/google/uuid"
"time"
)
type Chirp struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Body string `json:"body"`
UserID uuid.UUID `json:"user_id"`
}
type User struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Email string `json:"email"`
IsChirpyRed bool `json:"is_chirpy_red"`
}
type UserWithToken struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Email string `json:"email"`
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
IsChirpyRed bool `json:"is_chirpy_red"`
}

View File

@@ -0,0 +1,36 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: CreateChirp.sql
package database
import (
"context"
"github.com/google/uuid"
)
const createChirp = `-- name: CreateChirp :one
INSERT INTO chirps (id, created_at, updated_at, body, user_id)
VALUES (gen_random_uuid(), NOW(), NOW(), $1, $2)
RETURNING id, created_at, updated_at, body, user_id
`
type CreateChirpParams struct {
Body string
UserID uuid.UUID
}
func (q *Queries) CreateChirp(ctx context.Context, arg CreateChirpParams) (Chirp, error) {
row := q.db.QueryRowContext(ctx, createChirp, arg.Body, arg.UserID)
var i Chirp
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Body,
&i.UserID,
)
return i, err
}

View File

@@ -0,0 +1,39 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: CreateRefreshToken.sql
package database
import (
"context"
"time"
"github.com/google/uuid"
)
const createRefreshToken = `-- name: CreateRefreshToken :one
INSERT INTO refresh_tokens (token, created_at, updated_at, user_id, expires_at, revoked_at)
VALUES ($1, NOW(), NOW(), $2, $3, null)
RETURNING token, created_at, updated_at, user_id, expires_at, revoked_at
`
type CreateRefreshTokenParams struct {
Token string
UserID uuid.UUID
ExpiresAt time.Time
}
func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) {
row := q.db.QueryRowContext(ctx, createRefreshToken, arg.Token, arg.UserID, arg.ExpiresAt)
var i RefreshToken
err := row.Scan(
&i.Token,
&i.CreatedAt,
&i.UpdatedAt,
&i.UserID,
&i.ExpiresAt,
&i.RevokedAt,
)
return i, err
}

View File

@@ -10,19 +10,26 @@ import (
) )
const createUser = `-- name: CreateUser :one const createUser = `-- name: CreateUser :one
INSERT INTO users (id, created_at, updated_at, email) INSERT INTO users (id, created_at, updated_at, email, hashed_password)
VALUES (gen_random_uuid(), NOW(), NOW(), $1) VALUES (gen_random_uuid(), NOW(), NOW(), $1, $2)
RETURNING id, created_at, updated_at, email RETURNING id, created_at, updated_at, email, hashed_password, is_chirpy_red
` `
func (q *Queries) CreateUser(ctx context.Context, email string) (User, error) { type CreateUserParams struct {
row := q.db.QueryRowContext(ctx, createUser, email) Email string
HashedPassword string
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRowContext(ctx, createUser, arg.Email, arg.HashedPassword)
var i User var i User
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Email, &i.Email,
&i.HashedPassword,
&i.IsChirpyRed,
) )
return i, err return i, err
} }

View File

@@ -0,0 +1,21 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: DeleteChirpByID.sql
package database
import (
"context"
"github.com/google/uuid"
)
const deleteChirpByID = `-- name: DeleteChirpByID :exec
DELETE FROM chirps WHERE id = $1
`
func (q *Queries) DeleteChirpByID(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteChirpByID, id)
return err
}

View File

@@ -0,0 +1,43 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: GetAllChirps.sql
package database
import (
"context"
)
const getAllChirps = `-- name: GetAllChirps :many
SELECT id, created_at, updated_at, body, user_id FROM chirps ORDER BY created_at
`
func (q *Queries) GetAllChirps(ctx context.Context) ([]Chirp, error) {
rows, err := q.db.QueryContext(ctx, getAllChirps)
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
}

View File

@@ -0,0 +1,29 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: GetChirpByID.sql
package database
import (
"context"
"github.com/google/uuid"
)
const getChirpByID = `-- name: GetChirpByID :one
SELECT id, created_at, updated_at, body, user_id FROM chirps WHERE id = $1
`
func (q *Queries) GetChirpByID(ctx context.Context, id uuid.UUID) (Chirp, error) {
row := q.db.QueryRowContext(ctx, getChirpByID, id)
var i Chirp
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Body,
&i.UserID,
)
return i, err
}

View File

@@ -0,0 +1,45 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: GetChirpsByUserID.sql
package database
import (
"context"
"github.com/google/uuid"
)
const getChirpsByUserID = `-- name: GetChirpsByUserID :many
SELECT id, created_at, updated_at, body, user_id FROM chirps WHERE user_id = $1 ORDER BY created_at
`
func (q *Queries) GetChirpsByUserID(ctx context.Context, userID uuid.UUID) ([]Chirp, error) {
rows, err := q.db.QueryContext(ctx, getChirpsByUserID, 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
}

View File

@@ -0,0 +1,28 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: GetRefreshTokenByToken.sql
package database
import (
"context"
)
const getRefreshTokenByToken = `-- name: GetRefreshTokenByToken :one
SELECT token, created_at, updated_at, user_id, expires_at, revoked_at FROM refresh_tokens WHERE token = $1 AND revoked_at is null
`
func (q *Queries) GetRefreshTokenByToken(ctx context.Context, token string) (RefreshToken, error) {
row := q.db.QueryRowContext(ctx, getRefreshTokenByToken, token)
var i RefreshToken
err := row.Scan(
&i.Token,
&i.CreatedAt,
&i.UpdatedAt,
&i.UserID,
&i.ExpiresAt,
&i.RevokedAt,
)
return i, err
}

View File

@@ -0,0 +1,28 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: GetUserByEmail.sql
package database
import (
"context"
)
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, created_at, updated_at, email, hashed_password, is_chirpy_red FROM users WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByEmail, email)
var i User
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Email,
&i.HashedPassword,
&i.IsChirpyRed,
)
return i, err
}

View File

@@ -0,0 +1,30 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: GetUserByID.sql
package database
import (
"context"
"github.com/google/uuid"
)
const getUserByID = `-- name: GetUserByID :one
SELECT id, created_at, updated_at, email, hashed_password, is_chirpy_red FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Email,
&i.HashedPassword,
&i.IsChirpyRed,
)
return i, err
}

View File

@@ -0,0 +1,19 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: RevokeRefreshTokenByToken.sql
package database
import (
"context"
)
const revokeRefreshTokenByToken = `-- name: RevokeRefreshTokenByToken :exec
UPDATE refresh_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE token = $1
`
func (q *Queries) RevokeRefreshTokenByToken(ctx context.Context, token string) error {
_, err := q.db.ExecContext(ctx, revokeRefreshTokenByToken, token)
return err
}

View File

@@ -0,0 +1,37 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: UpdateUserEmailAndPassword.sql
package database
import (
"context"
"github.com/google/uuid"
)
const updateUserEmailAndPassword = `-- name: UpdateUserEmailAndPassword :one
UPDATE users SET email = $1, hashed_password = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3
RETURNING id, created_at, updated_at, email, hashed_password, is_chirpy_red
`
type UpdateUserEmailAndPasswordParams struct {
Email string
HashedPassword string
ID uuid.UUID
}
func (q *Queries) UpdateUserEmailAndPassword(ctx context.Context, arg UpdateUserEmailAndPasswordParams) (User, error) {
row := q.db.QueryRowContext(ctx, updateUserEmailAndPassword, arg.Email, arg.HashedPassword, arg.ID)
var i User
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Email,
&i.HashedPassword,
&i.IsChirpyRed,
)
return i, err
}

View File

@@ -0,0 +1,22 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: UpgradeUserToChirpyRed.sql
package database
import (
"context"
"github.com/google/uuid"
)
const upgradeUserToChirpyRed = `-- name: UpgradeUserToChirpyRed :exec
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) UpgradeUserToChirpyRed(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, upgradeUserToChirpyRed, id)
return err
}

View File

@@ -5,14 +5,34 @@
package database package database
import ( import (
"database/sql"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
) )
type User struct { type Chirp struct {
ID uuid.UUID ID uuid.UUID
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
Email string Body string
UserID uuid.UUID
}
type RefreshToken struct {
Token string
CreatedAt time.Time
UpdatedAt time.Time
UserID uuid.UUID
ExpiresAt time.Time
RevokedAt sql.NullTime
}
type User struct {
ID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
Email string
HashedPassword string
IsChirpyRed bool
} }

520
main.go
View File

@@ -5,21 +5,28 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/google/uuid"
"github.com/joho/godotenv" "github.com/joho/godotenv"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"github.com/rdarius/go-http-server/internal/auth"
"github.com/rdarius/go-http-server/internal/dataMaps"
"github.com/rdarius/go-http-server/internal/database" "github.com/rdarius/go-http-server/internal/database"
"github.com/rdarius/go-http-server/internal/httpResponse" "github.com/rdarius/go-http-server/internal/httpResponse"
"log" "log"
"net/http" "net/http"
"os" "os"
"regexp" "regexp"
"slices"
"sync/atomic" "sync/atomic"
"time"
) )
type apiConfig struct { type apiConfig struct {
fileserverHits atomic.Int32 fileserverHits atomic.Int32
db database.Queries db database.Queries
platform string platform string
secret string
polkaKey string
} }
func (cfg *apiConfig) middlewareMetricsInc(next http.Handler) http.Handler { func (cfg *apiConfig) middlewareMetricsInc(next http.Handler) http.Handler {
@@ -74,10 +81,382 @@ func fileServerHandler() http.Handler {
return http.StripPrefix("/app", fileServer) return http.StripPrefix("/app", fileServer)
} }
func postUsersHandler(cfg *apiConfig) http.HandlerFunc { func getAllChirpsHandler(cfg *apiConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("author_id")
sort := r.URL.Query().Get("sort")
fmt.Println(sort)
var chirps []database.Chirp
var err error
var id uuid.UUID
if len(q) == 0 {
chirps, err = cfg.db.GetAllChirps(context.Background())
if err != nil {
httpResponse.SomethingWentWrong(w)
return
}
} else {
id, err = uuid.Parse(q)
if err != nil {
httpResponse.SomethingWentWrong(w)
return
}
chirps, err = cfg.db.GetChirpsByUserID(context.Background(), id)
if err != nil {
httpResponse.SomethingWentWrong(w)
return
}
}
data := make([]dataMaps.Chirp, 0)
for _, chirp := range chirps {
data = append(data, dataMaps.Chirp{
ID: chirp.ID,
CreatedAt: chirp.CreatedAt,
UpdatedAt: chirp.UpdatedAt,
Body: chirp.Body,
UserID: chirp.UserID,
})
}
if sort == "desc" {
slices.SortFunc(data, func(a, b dataMaps.Chirp) int {
if b.CreatedAt.After(a.CreatedAt) {
return 1
}
if b.CreatedAt.Before(a.CreatedAt) {
return -1
}
return 0
})
}
jsonData, err := json.Marshal(data)
if err != nil {
httpResponse.SomethingWentWrong(w)
}
httpResponse.PlainTextHandler(w, http.StatusOK, string(jsonData))
}
}
func getChirpByIDHandler(cfg *apiConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
chirpID, err := uuid.Parse(r.PathValue("chirpID"))
if err != nil {
httpResponse.JSONHandler(w, http.StatusBadRequest, `{"error": "InvalidChirpID"}`)
return
}
chirp, err := cfg.db.GetChirpByID(context.Background(), chirpID)
if err != nil {
httpResponse.JSONHandler(w, http.StatusNotFound, `{"error": "ChirpNotFound"}`)
return
}
data := dataMaps.Chirp{
ID: chirp.ID,
CreatedAt: chirp.CreatedAt,
UpdatedAt: chirp.UpdatedAt,
Body: chirp.Body,
UserID: chirp.UserID,
}
jsonData, err := json.Marshal(data)
if err != nil {
httpResponse.SomethingWentWrong(w)
return
}
httpResponse.PlainTextHandler(w, http.StatusOK, string(jsonData))
}
}
func deleteChirpByIDHandler(cfg *apiConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, err := auth.GetBearerToken(r.Header)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
userId, err := auth.ValidateJWT(token, cfg.secret)
_, err = cfg.db.GetUserByID(context.Background(), userId)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
chirpID, err := uuid.Parse(r.PathValue("chirpID"))
if err != nil {
httpResponse.JSONHandler(w, http.StatusBadRequest, `{"error": "InvalidChirpID"}`)
return
}
chirp, err := cfg.db.GetChirpByID(context.Background(), chirpID)
if err != nil {
httpResponse.JSONHandler(w, http.StatusNotFound, `{"error": "ChirpNotFound"}`)
return
}
if chirp.UserID != userId {
httpResponse.JSONHandler(w, http.StatusForbidden, `{"error": "Forbidden"}`)
return
}
err = cfg.db.DeleteChirpByID(context.Background(), chirp.ID)
if err != nil {
httpResponse.SomethingWentWrong(w)
return
}
httpResponse.PlainTextHandler(w, http.StatusNoContent, "")
}
}
func polkaWebhookHandler(cfg *apiConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key, err := auth.GetAPIKey(r.Header)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
if key != cfg.polkaKey {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
type polkaWebhook struct {
Event string `json:"event"`
Data struct {
UserId string `json:"user_id"`
} `json:"data"`
}
data := polkaWebhook{}
err = json.NewDecoder(r.Body).Decode(&data)
if err != nil {
httpResponse.SomethingWentWrong(w)
return
}
if data.Event != "user.upgraded" {
httpResponse.JSONHandler(w, http.StatusNoContent, "")
return
}
userID, err := uuid.Parse(data.Data.UserId)
if err != nil {
httpResponse.JSONHandler(w, http.StatusBadRequest, `{"error": "InvalidUserID"}`)
return
}
_, err = cfg.db.GetUserByID(context.Background(), userID)
if err != nil {
httpResponse.JSONHandler(w, http.StatusNotFound, `{"error": "UserNotFound"}`)
return
}
err = cfg.db.UpgradeUserToChirpyRed(context.Background(), userID)
if err != nil {
httpResponse.SomethingWentWrong(w)
return
}
httpResponse.PlainTextHandler(w, http.StatusNoContent, "")
}
}
func updateUserDatahandler(cfg *apiConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
type parameters struct { type parameters struct {
Email string `json:"email"` Password string `json:"password"`
Email string `json:"email"`
}
token, err := auth.GetBearerToken(r.Header)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
userId, err := auth.ValidateJWT(token, cfg.secret)
_, err = cfg.db.GetUserByID(context.Background(), userId)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
params := parameters{}
err = json.NewDecoder(r.Body).Decode(&params)
if err != nil {
httpResponse.JSONHandler(w, http.StatusBadRequest, `{"error": "Failed to parse request body"}`)
return
}
hashedPassword, err := auth.HashPassword(params.Password)
if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, `{"error": "Failed to hash password"}`)
return
}
user, err := cfg.db.UpdateUserEmailAndPassword(context.Background(), database.UpdateUserEmailAndPasswordParams{
Email: params.Email,
HashedPassword: hashedPassword,
ID: userId,
})
if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
userJSON := dataMaps.User{
ID: user.ID,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
Email: user.Email,
IsChirpyRed: user.IsChirpyRed,
}
jsonData, err := json.Marshal(userJSON)
if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
httpResponse.JSONHandler(w, http.StatusOK, string(jsonData))
}
}
func postChirpsHandler(cfg *apiConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type parameters struct {
Body string `json:"body"`
}
token, err := auth.GetBearerToken(r.Header)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
userId, err := auth.ValidateJWT(token, cfg.secret)
params := parameters{}
err = json.NewDecoder(r.Body).Decode(&params)
if err != nil {
httpResponse.JSONHandler(w, http.StatusBadRequest, `{"error": "Failed to parse request body"}`)
return
}
_, err = cfg.db.GetUserByID(context.Background(), userId)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
chirp, err := validateChirp(params.Body)
if err != nil {
httpResponse.JSONHandler(w, http.StatusBadRequest, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
newChirp, err := cfg.db.CreateChirp(context.Background(), database.CreateChirpParams{
Body: chirp,
UserID: userId,
})
if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
chirpJSON := dataMaps.Chirp{
ID: newChirp.ID,
CreatedAt: newChirp.CreatedAt,
UpdatedAt: newChirp.UpdatedAt,
Body: newChirp.Body,
UserID: newChirp.UserID,
}
jsonData, err := json.Marshal(chirpJSON)
if err != nil {
httpResponse.SomethingWentWrong(w)
return
}
httpResponse.JSONHandler(w, http.StatusCreated, string(jsonData))
}
}
func refreshTokenHandler(cfg *apiConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, err := auth.GetBearerToken(r.Header)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
find, err := cfg.db.GetRefreshTokenByToken(context.Background(), token)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
_, err = cfg.db.GetUserByID(context.Background(), find.UserID)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
if find.RevokedAt.Valid {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
newToken, err := auth.MakeJWT(find.UserID, cfg.secret, time.Duration(3600)*time.Second)
if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
httpResponse.JSONHandler(w, http.StatusOK, fmt.Sprintf(`{"token": "%s"}`, newToken))
}
}
func revokeRefreshTokenHandler(cfg *apiConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, err := auth.GetBearerToken(r.Header)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
find, err := cfg.db.GetRefreshTokenByToken(context.Background(), token)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`)
return
}
err = cfg.db.RevokeRefreshTokenByToken(context.Background(), find.Token)
if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
httpResponse.JSONHandler(w, http.StatusNoContent, "")
}
}
func loginHandler(cfg *apiConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type parameters struct {
Email string `json:"email"`
Password string `json:"password"`
} }
params := parameters{} params := parameters{}
@@ -87,52 +466,119 @@ func postUsersHandler(cfg *apiConfig) http.HandlerFunc {
return return
} }
usr, err := cfg.db.CreateUser(context.Background(), params.Email) user, err := cfg.db.GetUserByEmail(context.Background(), params.Email)
if err != nil {
httpResponse.JSONHandler(w, http.StatusNotFound, `{"error": "UserNotFound"}`)
return
}
err = auth.CheckPasswordHash(params.Password, user.HashedPassword)
if err != nil {
httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Incorrect email or password"}`)
return
}
userJSON := dataMaps.UserWithToken{
ID: user.ID,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
Email: user.Email,
IsChirpyRed: user.IsChirpyRed,
}
expiresIn := time.Duration(3600) * time.Second
userJSON.Token, err = auth.MakeJWT(user.ID, cfg.secret, expiresIn)
if err != nil {
httpResponse.JSONHandler(w, http.StatusBadRequest, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
userJSON.RefreshToken, err = auth.MakeRefreshToken()
if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
_, err = cfg.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
Token: userJSON.RefreshToken,
UserID: userJSON.ID,
ExpiresAt: time.Now().UTC().Add(time.Duration(60*24*3600) * time.Second),
})
if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
jsonData, err := json.Marshal(userJSON)
if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
httpResponse.JSONHandler(w, http.StatusOK, string(jsonData))
}
}
func postUsersHandler(cfg *apiConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type parameters struct {
Email string `json:"email"`
Password string `json:"password"`
}
params := parameters{}
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
httpResponse.JSONHandler(w, http.StatusBadRequest, `{"error": "Failed to parse request body"}`)
return
}
hash, err := auth.HashPassword(params.Password)
if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
usr, err := cfg.db.CreateUser(context.Background(), database.CreateUserParams{
Email: params.Email,
HashedPassword: hash,
})
if err != nil { if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "Failed to create user", "message": "%s"}`, err.Error())) httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "Failed to create user", "message": "%s"}`, err.Error()))
return return
} }
jsonData := fmt.Sprintf(`{ userJSON := dataMaps.User{
"id": "%s", ID: usr.ID,
"created_at": "%s", CreatedAt: usr.CreatedAt,
"updated_at": "%s", UpdatedAt: usr.UpdatedAt,
"email": "%s" Email: usr.Email,
}`, usr.ID, usr.CreatedAt, usr.UpdatedAt, usr.Email) IsChirpyRed: usr.IsChirpyRed,
httpResponse.JSONHandler(w, http.StatusCreated, jsonData) }
jsonData, err := json.Marshal(userJSON)
if err != nil {
httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error()))
return
}
httpResponse.JSONHandler(w, http.StatusCreated, string(jsonData))
} }
} }
func validateChirpHandler(w http.ResponseWriter, r *http.Request) { func validateChirp(chirp string) (string, error) {
type parameters struct {
Body string `json:"body"`
}
profaneWords := []string{"kerfuffle", "sharbert", "fornax"} profaneWords := []string{"kerfuffle", "sharbert", "fornax"}
decoder := json.NewDecoder(r.Body) if len(chirp) > 140 {
params := parameters{} return "", fmt.Errorf("chirp is too long")
err := decoder.Decode(&params)
if err != nil {
httpResponse.SomethingWentWrong(w)
return
} }
if len(params.Body) > 140 {
httpResponse.JSONHandler(w, http.StatusBadRequest, `{"error": "Chirp is too long"}`)
return
}
chirp := params.Body
for _, word := range profaneWords { for _, word := range profaneWords {
// Use word boundaries (\b) to match whole words and ignore punctuation
re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(word) + `\b`) re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(word) + `\b`)
chirp = re.ReplaceAllString(chirp, "****") chirp = re.ReplaceAllString(chirp, "****")
} }
httpResponse.JSONHandler(w, http.StatusOK, fmt.Sprintf(`{"cleaned_body": "%s"}`, chirp)) return chirp, nil
return
} }
func main() { func main() {
@@ -145,6 +591,8 @@ func main() {
dbURL := os.Getenv("DB_URL") dbURL := os.Getenv("DB_URL")
apiCfg.platform = os.Getenv("PLATFORM") apiCfg.platform = os.Getenv("PLATFORM")
apiCfg.secret = os.Getenv("SECRET")
apiCfg.polkaKey = os.Getenv("POLKA_KEY")
db, err := sql.Open("postgres", dbURL) db, err := sql.Open("postgres", dbURL)
if err != nil { if err != nil {
@@ -159,8 +607,16 @@ func main() {
mux.HandleFunc("GET /api/healthz", readinessHandler) mux.HandleFunc("GET /api/healthz", readinessHandler)
mux.HandleFunc("GET /api/metrics", metricsHandler(apiCfg)) mux.HandleFunc("GET /api/metrics", metricsHandler(apiCfg))
mux.HandleFunc("POST /api/reset", resetMetricsHandler(apiCfg)) mux.HandleFunc("POST /api/reset", resetMetricsHandler(apiCfg))
mux.HandleFunc("POST /api/validate_chirp", validateChirpHandler)
mux.HandleFunc("POST /api/users", postUsersHandler(apiCfg)) mux.HandleFunc("POST /api/users", postUsersHandler(apiCfg))
mux.HandleFunc("POST /api/login", loginHandler(apiCfg))
mux.HandleFunc("POST /api/refresh", refreshTokenHandler(apiCfg))
mux.HandleFunc("POST /api/revoke", revokeRefreshTokenHandler(apiCfg))
mux.HandleFunc("POST /api/chirps", postChirpsHandler(apiCfg))
mux.HandleFunc("GET /api/chirps", getAllChirpsHandler(apiCfg))
mux.HandleFunc("GET /api/chirps/{chirpID}", getChirpByIDHandler(apiCfg))
mux.HandleFunc("DELETE /api/chirps/{chirpID}", deleteChirpByIDHandler(apiCfg))
mux.HandleFunc("POST /api/polka/webhooks", polkaWebhookHandler(apiCfg))
mux.HandleFunc("PUT /api/users", updateUserDatahandler(apiCfg))
mux.HandleFunc("GET /admin/metrics", adminMetricsHandler(apiCfg)) mux.HandleFunc("GET /admin/metrics", adminMetricsHandler(apiCfg))
mux.HandleFunc("POST /admin/reset", resetMetricsHandler(apiCfg)) mux.HandleFunc("POST /admin/reset", resetMetricsHandler(apiCfg))

View File

@@ -0,0 +1,4 @@
-- name: CreateChirp :one
INSERT INTO chirps (id, created_at, updated_at, body, user_id)
VALUES (gen_random_uuid(), NOW(), NOW(), $1, $2)
RETURNING *;

View File

@@ -0,0 +1,4 @@
-- name: CreateRefreshToken :one
INSERT INTO refresh_tokens (token, created_at, updated_at, user_id, expires_at, revoked_at)
VALUES ($1, NOW(), NOW(), $2, $3, null)
RETURNING *;

View File

@@ -1,4 +1,4 @@
-- name: CreateUser :one -- name: CreateUser :one
INSERT INTO users (id, created_at, updated_at, email) INSERT INTO users (id, created_at, updated_at, email, hashed_password)
VALUES (gen_random_uuid(), NOW(), NOW(), $1) VALUES (gen_random_uuid(), NOW(), NOW(), $1, $2)
RETURNING *; RETURNING *;

View File

@@ -0,0 +1,2 @@
-- name: DeleteChirpByID :exec
DELETE FROM chirps WHERE id = $1;

View File

@@ -0,0 +1,2 @@
-- name: GetAllChirps :many
SELECT * FROM chirps ORDER BY created_at;

View File

@@ -0,0 +1,2 @@
-- name: GetChirpByID :one
SELECT * FROM chirps WHERE id = $1;

View File

@@ -0,0 +1,2 @@
-- name: GetChirpsByUserID :many
SELECT * FROM chirps WHERE user_id = $1 ORDER BY created_at;

View File

@@ -0,0 +1,2 @@
-- name: GetRefreshTokenByToken :one
SELECT * FROM refresh_tokens WHERE token = $1 AND revoked_at is null;

View File

@@ -0,0 +1,2 @@
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1;

View File

@@ -0,0 +1,2 @@
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;

View File

@@ -0,0 +1,2 @@
-- name: RevokeRefreshTokenByToken :exec
UPDATE refresh_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE token = $1;

View File

@@ -0,0 +1,3 @@
-- name: UpdateUserEmailAndPassword :one
UPDATE users SET email = $1, hashed_password = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3
RETURNING *;

View File

@@ -0,0 +1,3 @@
-- name: UpgradeUserToChirpyRed :exec
UPDATE users SET is_chirpy_red = true WHERE id = $1
RETURNING *;

11
sql/schema/002_chirps.sql Normal file
View File

@@ -0,0 +1,11 @@
-- +goose Up
CREATE TABLE chirps(
id UUID PRIMARY KEY,
created_at TIMESTAMP not null DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP not null DEFAULT CURRENT_TIMESTAMP,
body TEXT not null UNIQUE,
user_id UUID not null REFERENCES users(id) ON DELETE CASCADE
);
-- +goose Down
DROP TABLE chirps;

View File

@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE users ADD COLUMN hashed_password TEXT NOT NULL DEFAULT 'unset';
-- +goose Down
ALTER TABLE users DROP COLUMN hashed_password;

View File

@@ -0,0 +1,12 @@
-- +goose Up
CREATE TABLE refresh_tokens(
token TEXT PRIMARY KEY,
created_at TIMESTAMP not null DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP not null DEFAULT CURRENT_TIMESTAMP,
user_id UUID not null REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamp not null,
revoked_at timestamp default null
);
-- +goose Down
DROP TABLE refresh_tokens;

View File

@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE users ADD COLUMN is_chirpy_red BOOLEAN NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE users DROP COLUMN is_chirpy_red;