diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b7e776a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# CHANGELOG + +## Version 0.0.1 + +#### NOTE: the code snippet schema changed. + +For creating the code snippet, there should be two requests: + +1) POST /api/v1/code_snippet -> Title, UserID (optional) +2) POST /api/v1/code_snippet_version -> CodeSnippetID, ProgramLanguageID, Text + +This is done for being able to have versioning for the code snippets. + +Features List: + +- Code Snippets: Added GET /api/v1/user_code_snippets to retrieve user code snippets +- Code Snippets: Added POST /api/v1/review_comment to create reviews for code snippets +- Code Snippets: Added POST /api/v1/code_snippet_version to create a new version of the code snippet +- Authorization: Added POST /api/v1/register and POST /api/v1/login +- Authorization: Added JWT token generation and check for login / signup +- Authorization: Added validation of username and password +- Security: Added security middleware +- Database: Programming languages are inserted with the first migration diff --git a/README.md b/README.md index e3bdbe5..d12f26b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ DB_PORT=5432 DB_USER=postgres DB_PASSWORD=postgres DB_NAME=codereview +JWT_SECRET_KEY=secret ``` And ensure that you have `db.env` file with the following contents (input your own password): @@ -61,7 +62,7 @@ atlas migrate diff --env gorm To apply the migration, use: ``` -atlas migrate apply -u "postgres://postgres:postgres@localhost:5432/codereview" +atlas migrate apply -u "postgres://postgres:postgres@localhost:5432/codereview?sslmode=disable" ``` To downgrade: diff --git a/go.mod b/go.mod index 410f21c..8eb8196 100644 --- a/go.mod +++ b/go.mod @@ -18,11 +18,13 @@ require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/alecthomas/kong v0.9.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -43,9 +45,9 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.52.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - golang.org/x/crypto v0.21.0 // indirect + golang.org/x/crypto v0.22.0 // indirect golang.org/x/mod v0.17.0 // indirect - golang.org/x/sys v0.18.0 // indirect + golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.19.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index f3d0773..e93ad68 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -46,6 +48,7 @@ github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtg github.com/gofiber/swagger v1.0.0 h1:BzUzDS9ZT6fDUa692kxmfOjc1DZiloLiPK/W5z1H1tc= github.com/gofiber/swagger v1.0.0/go.mod h1:QrYNF1Yrc7ggGK6ATsJ6yfH/8Zi5bu9lA7wB8TmCecg= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= @@ -145,6 +148,8 @@ golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0 golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= @@ -180,6 +185,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/handler/code_snippet_handler.go b/handler/code_snippet_handler.go index 788fcd2..1e31ed1 100644 --- a/handler/code_snippet_handler.go +++ b/handler/code_snippet_handler.go @@ -8,11 +8,11 @@ import ( ) type CodeSnippetForm struct { - ProgramLanguageID uuid.UUID `json:"program_language_id" description:"The UUID of the program language"` - Text string `json:"text" description:"The text of the code snippet, its content"` - IsPrivate bool `json:"is_private" description:"Whether the code snippet is private"` - IsArchived bool `json:"is_archived" description:"Whether the code snippet is archived"` - IsDraft bool `json:"is_draft" description:"Whether the code snippet is a draft"` + UserID uuid.UUID `json:"user_id" description:"UUID of the user"` + Title string `json:"title" description:"The title of the code snippet"` + IsPrivate bool `json:"is_private" description:"Whether the code snippet is private"` + IsArchived bool `json:"is_archived" description:"Whether the code snippet is archived"` + IsDraft bool `json:"is_draft" description:"Whether the code snippet is a draft"` } type ProgramLanguageForm struct { @@ -35,7 +35,12 @@ func GetAllCodeSnippets(c *fiber.Ctx) error { db := database.DB.Db var code_snippets []model.CodeSnippet - db.Find(&code_snippets).Order("created_at desc") + // TODO: Select only needed fields, exclude user.password :) + db.Preload("CodeSnippetVersions"). + Preload("User"). + Preload("CodeSnippetVersions.ProgramLanguage"). + Order("created_at desc"). + Find(&code_snippets) if len(code_snippets) == 0 { return c.Status(404).JSON(fiber.Map{ @@ -62,7 +67,12 @@ func GetSingleCodeSnippet(c *fiber.Ctx) error { id := c.Params("id") var code_snippet model.CodeSnippet - db.Where("code_snippet_id = ?", id).First(&code_snippet) + // TODO: Select only needed fields, exclude user.password :) + db.Preload("CodeSnippetVersions"). + Preload("User"). + Preload("CodeSnippetVersions.ProgramLanguage"). + Preload("CodeSnippetVersions.ReviewComments"). + Preload("CodeSnippetVersions.CodeSnippetRatings").Where("code_snippet_id = ?", id).First(&code_snippet) if code_snippet.CodeSnippetID == uuid.Nil { return c.Status(404).JSON(fiber.Map{ @@ -85,14 +95,6 @@ func GetSingleCodeSnippet(c *fiber.Ctx) error { func CreateCodeSnippet(c *fiber.Ctx) error { db := database.DB.Db - user := new(model.User) - user.Username = GenerateRandomUsername(8) - if result := db.Create(&user); result.Error != nil { - return c.Status(500).JSON(fiber.Map{ - "status": "error", "message": "Could not create anonymous user", "data": result.Error, - }) - } - code_snippet := new(model.CodeSnippet) if err := c.BodyParser(code_snippet); err != nil { return c.Status(400).JSON(fiber.Map{ @@ -100,12 +102,6 @@ func CreateCodeSnippet(c *fiber.Ctx) error { }) } - program_language := new(model.ProgramLanguage) - db.First(&program_language, code_snippet.ProgramLanguageID) - - code_snippet.UserID = user.UserID - code_snippet.User = *user - code_snippet.ProgramLanguage = *program_language if result := db.Create(&code_snippet); result.Error != nil { return c.Status(500).JSON(fiber.Map{ "status": "error", "message": "Could not create code snippet", "data": result.Error, diff --git a/handler/code_snippet_version_handler.go b/handler/code_snippet_version_handler.go new file mode 100644 index 0000000..93edf9e --- /dev/null +++ b/handler/code_snippet_version_handler.go @@ -0,0 +1,37 @@ +package handler + +import ( + "github.com/abinba/codereview/database" + "github.com/abinba/codereview/model" + "github.com/gofiber/fiber/v2" +) + +// CreateCodeSnippet godoc +// @Summary Create a code snippet +// @Description Create a code snippet +// @Tags Code Snippets +// @Accept json +// @Produce json +// @Param code_snippet body CodeSnippetVersion true "Code Snippet information to create" +// @Success 201 {object} model.CodeSnippet +// @Router /api/v1/code_snippet/ [post] +func CreateCodeSnippetVersion(c *fiber.Ctx) error { + db := database.DB.Db + + code_snippet := new(model.CodeSnippetVersion) + if err := c.BodyParser(code_snippet); err != nil { + return c.Status(400).JSON(fiber.Map{ + "status": "error", "message": "Could not parse request", "data": err, + }) + } + + if result := db.Create(&code_snippet); result.Error != nil { + return c.Status(500).JSON(fiber.Map{ + "status": "error", "message": "Could not create code snippet version", "data": result.Error, + }) + } + + return c.Status(201).JSON(fiber.Map{ + "status": "success", "message": "Code Snippet Version has been created", "data": code_snippet, + }) +} diff --git a/handler/review_comment_handler.go b/handler/review_comment_handler.go new file mode 100644 index 0000000..21d0a74 --- /dev/null +++ b/handler/review_comment_handler.go @@ -0,0 +1,37 @@ +package handler + +import ( + "github.com/abinba/codereview/database" + "github.com/abinba/codereview/model" + "github.com/gofiber/fiber/v2" +) + +// CreateReviewComment creates a review comment. +// @Summary Create a review comment +// @Description Adds a new review comment to the database. +// @Tags Review Comments +// @Accept json +// @Produce json +// @Param review_comment body ReviewComment true "Review comment information to create" +// @Success 201 {object} model.ReviewComment +// @Router /api/v1/review_comment/ [post] +func CreateReviewComment(c *fiber.Ctx) error { + db := database.DB.Db + + review_comment := new(model.ReviewComment) + if err := c.BodyParser(review_comment); err != nil { + return c.Status(400).JSON(fiber.Map{ + "status": "error", "message": "Could not parse request", "data": err, + }) + } + + if result := db.Create(&review_comment); result.Error != nil { + return c.Status(500).JSON(fiber.Map{ + "status": "error", "message": "Could not create review comment", "data": result.Error, + }) + } + + return c.Status(201).JSON(fiber.Map{ + "status": "success", "message": "Review comment has been created", "data": review_comment, + }) +} diff --git a/handler/user_code_snippets.go b/handler/user_code_snippets.go new file mode 100644 index 0000000..e9e7228 --- /dev/null +++ b/handler/user_code_snippets.go @@ -0,0 +1,42 @@ +package handler + +import ( + "github.com/abinba/codereview/database" + "github.com/abinba/codereview/model" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +// GetUserCodeSnippets godoc +// @Summary Get user's code snippets +// @Description Retrieve all code snippets created by a specific user +// @Tags User Code Snippets +// @Accept json +// @Produce json +// @Param user_id path string true "User ID" +// @Success 200 {array} model.CodeSnippet +// @Router /api/v1/user_code_snippets/{user_id} [get] +func GetUserCodeSnippets(c *fiber.Ctx) error { + db := database.DB.Db + userID := c.Params("id") + + var userUUID uuid.UUID + if err := userUUID.UnmarshalText([]byte(userID)); err != nil { + return c.Status(400).JSON(fiber.Map{ + "status": "error", "message": "Invalid user ID format", "data": nil, + }) + } + + var code_snippets []model.CodeSnippet + db.Where("user_id = ?", userUUID).Preload("CodeSnippetVersions").Preload("User").Find(&code_snippets) + + if len(code_snippets) == 0 { + return c.Status(404).JSON(fiber.Map{ + "status": "error", "message": "No code snippets found for the user", "data": nil, + }) + } + + return c.Status(200).JSON(fiber.Map{ + "status": "success", "message": "User code snippets found", "data": code_snippets, + }) +} diff --git a/handler/user_handler.go b/handler/user_handler.go index 00a2b76..92433a2 100644 --- a/handler/user_handler.go +++ b/handler/user_handler.go @@ -1,14 +1,42 @@ package handler import ( + "github.com/abinba/codereview/middleware" "github.com/abinba/codereview/database" "github.com/abinba/codereview/model" + "github.com/abinba/codereview/repo" + "github.com/asaskevich/govalidator" "github.com/gofiber/fiber/v2" "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" ) type User struct { Username string `json:"username" example:"johndoe" description:"The username of the user"` + Password string `json:"password" description:"The password of the user"` +} + + +func validateUser(username, password string) (bool, string) { + if len(username) < 3 || len(username) > 30 || !govalidator.IsAlphanumeric(username) { + return false, "Username must be 3-30 characters long and alphanumeric." + } + passwordRequirements := map[string]string{ + "uppercase": `[A-Z]`, // Use at least one uppercase letter + "lowercase": `[a-z]`, // Use at least one lowercase letter + "number": `[0-9]`, // Use at least one digit + "special": `[^A-Za-z0-9]`, // Use at least one special character + } + for key, regexStr := range passwordRequirements { + matched := govalidator.StringMatches(password, regexStr) + if !matched { + return false, "Password must contain at least one " + key + } + } + if len(password) < 8 { + return false, "Password must be at least 8 characters long." + } + return true, "" } // CreateUser godoc @@ -19,43 +47,84 @@ type User struct { // @Produce json // @Param user body User true "User to create" example("{\"username\": \"John Doe\"}") // @Success 201 {object} model.User -// @Router /api/v1/user/ [post] +// @Router /api/v1/register/ [post] func CreateUser(c *fiber.Ctx) error { db := database.DB.Db - user := new(model.User) + credentials := new(User) - err := c.BodyParser(user) + err := c.BodyParser(credentials) if err != nil { return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Something's wrong with your input", "data": err}) } - err = db.Create(&user).Error // TODO: hash passwords + valid, message := validateUser(credentials.Username, credentials.Password) + if !valid { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "error", "message": message}) + } + + user := new(model.User) + err = db.Where("username = ?", credentials.Username).First(&user).Error + if err == nil { + return c.Status(400).JSON(fiber.Map{"status": "error", "message": "User already exists"}) + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) + if err != nil { + return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Failed to hash password", "data": err}) + } + + credentials.Password = string(hashedPassword) + + err = repo.NewUserRepository(db).CreateUser( + credentials.Username, credentials.Password, + ) if err != nil { return c.Status(500).JSON(fiber.Map{"status": "error", "message": "Could not create user", "data": err}) } - return c.Status(201).JSON(fiber.Map{"status": "success", "message": "User has been created", "data": user}) + return c.Status(201).JSON(fiber.Map{"status": "success", "message": "User has been created"}) } -// GetAllUsers godoc -// @Summary Get all users -// @Description get details of all users +// Login godoc +// @Summary User login +// @Description login a user by username and password // @Tags users // @Accept json // @Produce json -// @Success 200 {array} model.User -// @Router /api/v1/user/ [get] -func GetAllUsers(c *fiber.Ctx) error { +// @Param credentials body User true "Login credentials" example("{\"username\": \"johndoe\", \"password\": \"p@ssword123\"}") +// @Success 200 {string} string "login successful" +// @Failure 401 {string} string "invalid credentials" +// @Router /api/v1/login [post] +func Login(c *fiber.Ctx) error { db := database.DB.Db + credentials := new(User) + + if err := c.BodyParser(credentials); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "error", "message": "Invalid input", "data": err}) + } + + valid, message := validateUser(credentials.Username, credentials.Password) + if !valid { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"status": "error", "message": message}) + } - var users []model.User - db.Find(&users) + user := new(model.User) + err := db.Where("username = ?", credentials.Username).First(&user).Error + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"status": "error", "message": "User not found"}) + } - if len(users) == 0 { - return c.Status(404).JSON(fiber.Map{"status": "error", "message": "Users not found", "data": nil}) + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(credentials.Password)) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"status": "error", "message": "Invalid credentials"}) + } + + token, err := middleware.GenerateJWT(user.UserID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"status": "error", "message": "Failed to generate token", "data": err}) } - return c.Status(200).JSON(fiber.Map{"status": "sucess", "message": "Users Found", "data": users}) + return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "success", "message": "Login successful", "token": token}) } // GetSingleUser godoc @@ -138,7 +207,7 @@ func DeleteUserByID(c *fiber.Ctx) error { return c.Status(404).JSON(fiber.Map{"status": "error", "message": "User not found", "data": nil}) } - err := db.Delete(&user, "id = ?", id).Error + err := db.Delete(&user, "user_id = ?", id).Error if err != nil { return c.Status(404).JSON(fiber.Map{"status": "error", "message": "Failed to delete user", "data": nil}) diff --git a/main.go b/main.go index bb31f07..e6fa564 100644 --- a/main.go +++ b/main.go @@ -32,5 +32,5 @@ func main() { return c.SendStatus(404) }) - app.Listen(":8080") + app.Listen(":3000") } diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..38c845d --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,61 @@ +package middleware + +import ( + "fmt" + "strings" + + "github.com/abinba/codereview/config" + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + + "time" +) + +var jwtSecretKey = []byte(config.Config("JWT_SECRET_KEY")) + + +func GenerateJWT(UserID uuid.UUID) (string, error) { + claims := jwt.MapClaims{ + "user_id": UserID, + "exp": time.Now().Add(time.Minute * 180).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecretKey) +} + +func JWTProtected() fiber.Handler { + return func(c *fiber.Ctx) error { + authHeader := c.Get("Authorization") + + if authHeader == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"status": "error", "message": "Missing token"}) + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"status": "error", "message": "Invalid token format"}) + } + tokenString := parts[1] + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + return jwtSecretKey, nil + }) + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + c.Locals("user_id", claims["user_id"]) + if c.Params("id") != "" && claims["user_id"] != c.Params("id") { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"status": "error", "message": "Access denied"}) + } + + return c.Next() + } else { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"status": "error", "message": "Invalid token", "data": err}) + } + } +} diff --git a/middleware/security.go b/middleware/security.go new file mode 100644 index 0000000..018087e --- /dev/null +++ b/middleware/security.go @@ -0,0 +1,15 @@ +package middleware + +import "github.com/gofiber/fiber/v2" + +func Security(c *fiber.Ctx) error { + c.Set("X-XSS-Protection", "1; mode=block") + c.Set("X-Content-Type-Options", "nosniff") + c.Set("X-Download-Options", "noopen") + c.Set("Strict-Transport-Security", "max-age=5184000") + c.Set("X-Frame-Options", "DENY") + c.Set("X-DNS-Prefetch-Control", "off") + c.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH") + // c.Set("Content-Security-Policy", "default-src https:") + return c.Next() +} \ No newline at end of file diff --git a/migrations/20240412091025.sql b/migrations/20240412091025.sql deleted file mode 100644 index a240fb2..0000000 --- a/migrations/20240412091025.sql +++ /dev/null @@ -1,57 +0,0 @@ --- Create "users" table -CREATE TABLE "public"."users" ( - "user_id" uuid NOT NULL DEFAULT gen_random_uuid(), - "username" text NULL, - "is_anonymous" boolean NULL DEFAULT false, - "is_active" boolean NULL DEFAULT true, - "created_at" timestamptz NULL, - "updated_at" timestamptz NULL, - PRIMARY KEY ("user_id") -); --- Create "program_languages" table -CREATE TABLE "public"."program_languages" ( - "program_language_id" uuid NOT NULL DEFAULT gen_random_uuid(), - "name" text NULL, - PRIMARY KEY ("program_language_id") -); --- Create "code_snippets" table -CREATE TABLE "public"."code_snippets" ( - "code_snippet_id" uuid NOT NULL DEFAULT gen_random_uuid(), - "user_id" uuid NULL, - "program_language_id" uuid NULL, - "text" text NULL, - "is_private" boolean NULL DEFAULT false, - "is_archived" boolean NULL DEFAULT false, - "is_draft" boolean NULL DEFAULT false, - "created_at" timestamptz NULL, - "updated_at" timestamptz NULL, - PRIMARY KEY ("code_snippet_id"), - CONSTRAINT "fk_code_snippets_program_language" FOREIGN KEY ("program_language_id") REFERENCES "public"."program_languages" ("program_language_id") ON UPDATE NO ACTION ON DELETE NO ACTION, - CONSTRAINT "fk_code_snippets_user" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("user_id") ON UPDATE NO ACTION ON DELETE NO ACTION -); --- Create "code_snippet_ratings" table -CREATE TABLE "public"."code_snippet_ratings" ( - "code_snippet_rating_id" uuid NOT NULL DEFAULT gen_random_uuid(), - "code_snippet_id" uuid NULL, - "user_id" uuid NULL, - "rating" smallint NULL, - "created_at" timestamptz NULL, - "updated_at" timestamptz NULL, - PRIMARY KEY ("code_snippet_rating_id"), - CONSTRAINT "fk_code_snippet_ratings_user" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("user_id") ON UPDATE NO ACTION ON DELETE NO ACTION, - CONSTRAINT "fk_code_snippets_code_snippet_ratings" FOREIGN KEY ("code_snippet_id") REFERENCES "public"."code_snippets" ("code_snippet_id") ON UPDATE NO ACTION ON DELETE NO ACTION -); --- Create "review_comments" table -CREATE TABLE "public"."review_comments" ( - "comment_id" uuid NOT NULL DEFAULT gen_random_uuid(), - "user_id" uuid NULL, - "code_snippet_id" uuid NULL, - "reply_comment_id" text NULL, - "text" text NULL, - "line" bigint NULL, - "created_at" timestamptz NULL, - "updated_at" timestamptz NULL, - PRIMARY KEY ("comment_id"), - CONSTRAINT "fk_code_snippets_review_comments" FOREIGN KEY ("code_snippet_id") REFERENCES "public"."code_snippets" ("code_snippet_id") ON UPDATE NO ACTION ON DELETE NO ACTION, - CONSTRAINT "fk_review_comments_user" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("user_id") ON UPDATE NO ACTION ON DELETE NO ACTION -); diff --git a/migrations/20240425202222.sql b/migrations/20240425202222.sql new file mode 100644 index 0000000..cd69fa3 --- /dev/null +++ b/migrations/20240425202222.sql @@ -0,0 +1,94 @@ +-- Create "users" table +CREATE TABLE "public"."users" ( + "user_id" uuid NOT NULL DEFAULT gen_random_uuid(), + "username" text NOT NULL UNIQUE, + "password" text NOT NULL, + "is_active" boolean NULL DEFAULT true, + "created_at" timestamptz NULL, + "updated_at" timestamptz NULL, + PRIMARY KEY ("user_id") +); +-- Create "program_languages" table +CREATE TABLE "public"."program_languages" ( + "program_language_id" uuid NOT NULL DEFAULT gen_random_uuid(), + "name" text NOT NULL, + PRIMARY KEY ("program_language_id") +); +-- Create "code_snippets" table +CREATE TABLE "public"."code_snippets" ( + "code_snippet_id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NULL, + "title" text NOT NULL, + "is_private" boolean NULL DEFAULT false, + "is_archived" boolean NULL DEFAULT false, + "is_draft" boolean NULL DEFAULT false, + "created_at" timestamptz NULL, + "updated_at" timestamptz NULL, + PRIMARY KEY ("code_snippet_id"), + CONSTRAINT "fk_code_snippets_user" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("user_id") ON UPDATE NO ACTION ON DELETE NO ACTION +); +-- Create "code_snippet_versions" table +CREATE TABLE "public"."code_snippet_versions" ( + "code_snippet_version_id" uuid NOT NULL DEFAULT gen_random_uuid(), + "code_snippet_id" uuid NOT NULL, + "program_language_id" uuid NOT NULL, + "text" text NOT NULL, + "created_at" timestamptz NULL, + "updated_at" timestamptz NULL, + PRIMARY KEY ("code_snippet_version_id"), + CONSTRAINT "fk_code_snippet_versions_program_language" FOREIGN KEY ("program_language_id") REFERENCES "public"."program_languages" ("program_language_id") ON UPDATE NO ACTION ON DELETE NO ACTION, + CONSTRAINT "fk_code_snippets_code_snippet_versions" FOREIGN KEY ("code_snippet_id") REFERENCES "public"."code_snippets" ("code_snippet_id") ON UPDATE NO ACTION ON DELETE CASCADE +); +-- Create "code_snippet_ratings" table +CREATE TABLE "public"."code_snippet_ratings" ( + "code_snippet_rating_id" uuid NOT NULL DEFAULT gen_random_uuid(), + "code_snippet_version_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "rating" smallint NOT NULL, + "created_at" timestamptz NULL, + "updated_at" timestamptz NULL, + PRIMARY KEY ("code_snippet_rating_id"), + CONSTRAINT "fk_code_snippet_ratings_user" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("user_id") ON UPDATE NO ACTION ON DELETE NO ACTION, + CONSTRAINT "fk_code_snippet_versions_code_snippet_ratings" FOREIGN KEY ("code_snippet_version_id") REFERENCES "public"."code_snippet_versions" ("code_snippet_version_id") ON UPDATE NO ACTION ON DELETE NO ACTION +); +-- Create "notifications" table +CREATE TABLE "public"."notifications" ( + "notification_id" uuid NOT NULL DEFAULT gen_random_uuid(), + "notification_type" text NOT NULL, + "user_id" uuid NOT NULL, + "text" text NOT NULL, + "created_at" timestamptz NULL, + PRIMARY KEY ("notification_id"), + CONSTRAINT "fk_notifications_user" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("user_id") ON UPDATE NO ACTION ON DELETE NO ACTION +); +-- Create "review_comments" table +CREATE TABLE "public"."review_comments" ( + "comment_id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "code_snippet_version_id" uuid NOT NULL, + "reply_comment_id" uuid NULL, + "text" text NOT NULL, + "line" bigint NULL, + "is_generated" boolean NULL DEFAULT false, + "created_at" timestamptz NULL, + "updated_at" timestamptz NULL, + PRIMARY KEY ("comment_id"), + CONSTRAINT "fk_code_snippet_versions_review_comments" FOREIGN KEY ("code_snippet_version_id") REFERENCES "public"."code_snippet_versions" ("code_snippet_version_id") ON UPDATE NO ACTION ON DELETE NO ACTION, + CONSTRAINT "fk_review_comments_reply_comment" FOREIGN KEY ("reply_comment_id") REFERENCES "public"."review_comments" ("comment_id") ON UPDATE NO ACTION ON DELETE NO ACTION, + CONSTRAINT "fk_review_comments_user" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("user_id") ON UPDATE NO ACTION ON DELETE NO ACTION +); +-- Insert most popular programming languages +INSERT INTO public.program_languages (name) VALUES ('JavaScript'); +INSERT INTO public.program_languages (name) VALUES ('Python'); +INSERT INTO public.program_languages (name) VALUES ('Java'); +INSERT INTO public.program_languages (name) VALUES ('C#'); +INSERT INTO public.program_languages (name) VALUES ('C++'); +INSERT INTO public.program_languages (name) VALUES ('TypeScript'); +INSERT INTO public.program_languages (name) VALUES ('PHP'); +INSERT INTO public.program_languages (name) VALUES ('Ruby'); +INSERT INTO public.program_languages (name) VALUES ('Swift'); +INSERT INTO public.program_languages (name) VALUES ('Go'); +INSERT INTO public.program_languages (name) VALUES ('Kotlin'); +INSERT INTO public.program_languages (name) VALUES ('Rust'); +INSERT INTO public.program_languages (name) VALUES ('R'); +INSERT INTO public.program_languages (name) VALUES ('Scala'); diff --git a/migrations/20240425235457.sql b/migrations/20240425235457.sql new file mode 100644 index 0000000..7ea6520 --- /dev/null +++ b/migrations/20240425235457.sql @@ -0,0 +1,2 @@ +-- Modify "review_comments" table +ALTER TABLE "public"."review_comments" ALTER COLUMN "user_id" DROP NOT NULL; diff --git a/migrations/atlas.sum b/migrations/atlas.sum index a3accc0..f7502b4 100644 --- a/migrations/atlas.sum +++ b/migrations/atlas.sum @@ -1,2 +1,3 @@ -h1:1a0wJLlxUCSd0r6vqwRHZiGoez9hcF3GaXmACpLp5Ao= -20240412091025.sql h1:IX+2XSShDeV35uAEpq/dQIlVDRrwnPJBb/WdM3JuPO8= +h1:XYIxcthCR23F/vyIpKtlhAEX4uBJdIGpnmE0TEfeW10= +20240425202222.sql h1:soPLhrNf3kjJ7/VIwi0H07nUoFLrQUnDP0aQqyKL2P8= +20240425235457.sql h1:32ELkiV5mx7Idl0lRKtEZyZX/WGvICz3NkzjveL7rp0= diff --git a/model/code_rating.go b/model/code_rating.go index 41fcd47..e6c88ea 100644 --- a/model/code_rating.go +++ b/model/code_rating.go @@ -10,11 +10,10 @@ import ( // @Description CodeSnippetRating is the model representing a rating for a code snippet. type CodeSnippetRating struct { CodeSnippetRatingID uuid.UUID `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` - CodeSnippetID uuid.UUID - UserID uuid.UUID - Rating int8 + CodeSnippetVersionID uuid.UUID `gorm:"not null"` + UserID uuid.UUID `gorm:"not null"` + Rating int8 `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` User User - CodeSnippet CodeSnippet } diff --git a/model/code_snippet.go b/model/code_snippet.go index 84a2f57..8ec4a9c 100644 --- a/model/code_snippet.go +++ b/model/code_snippet.go @@ -10,16 +10,13 @@ import ( // @Description CodeSnippet is the model representing a code snippet in the system. type CodeSnippet struct { CodeSnippetID uuid.UUID `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` - UserID uuid.UUID - ProgramLanguageID uuid.UUID - Text string + UserID *uuid.UUID `gorm:"default:NULL"` + Title string `gorm:"not null"` IsPrivate *bool `gorm:"default:false"` IsArchived *bool `gorm:"default:false"` IsDraft *bool `gorm:"default:false"` - User User CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` - ReviewComments []ReviewComment - CodeSnippetRatings []CodeSnippetRating - ProgramLanguage ProgramLanguage + User *User + CodeSnippetVersions []CodeSnippetVersion `gorm:"constraint:OnDelete:CASCADE"` } diff --git a/model/code_snippet_version.go b/model/code_snippet_version.go new file mode 100644 index 0000000..1d9856f --- /dev/null +++ b/model/code_snippet_version.go @@ -0,0 +1,21 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +// CodeSnippetVersion is a code snippet version that is posted by a user. +// @Description CodeSnippetVersion is the model representing a code snippet version of the code snippet in the system. +type CodeSnippetVersion struct { + CodeSnippetVersionID uuid.UUID `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` + CodeSnippetID uuid.UUID `gorm:"not null"` + ProgramLanguageID uuid.UUID `gorm:"not null"` + Text string `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + ProgramLanguage ProgramLanguage + CodeSnippetRatings []CodeSnippetRating + ReviewComments []ReviewComment +} diff --git a/model/notification.go b/model/notification.go new file mode 100644 index 0000000..7494c49 --- /dev/null +++ b/model/notification.go @@ -0,0 +1,18 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +// Notification is a model for representing notifications for particular user in the system. +// @Description Notification is a model for representing notifications for particular user in the system. +type Notification struct { + NotificationID uuid.UUID `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` + NotificationType string `gorm:"not null"` + UserID uuid.UUID `gorm:"not null"` + Text string `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + User User +} diff --git a/model/program_language.go b/model/program_language.go index eed142f..56dfe49 100644 --- a/model/program_language.go +++ b/model/program_language.go @@ -8,5 +8,5 @@ import ( // @Description ProgramLanguage is the model representing a programming language in the system. type ProgramLanguage struct { ProgramLanguageID uuid.UUID `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` - Name string + Name string `gorm:"not null"` } diff --git a/model/review_comment.go b/model/review_comment.go index 86ae77b..9ce1abb 100644 --- a/model/review_comment.go +++ b/model/review_comment.go @@ -10,13 +10,15 @@ import ( // @Description ReviewComment is the model representing a comment on a code snippet. type ReviewComment struct { CommentID uuid.UUID `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` - UserID uuid.UUID - CodeSnippetID uuid.UUID - ReplyCommentID uuid.UUID - Text string - Line int + UserID *uuid.UUID + CodeSnippetVersionID uuid.UUID `gorm:"not null"` + ReplyCommentID *uuid.UUID + Text string `gorm:"not null"` + Line *int + IsGenerated bool `gorm:"default:false"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` - User User - CodeSnippet CodeSnippet + User *User + CodeSnippetVersion CodeSnippetVersion + ReplyComment *ReviewComment } diff --git a/model/user.go b/model/user.go index fec4e64..eed5600 100644 --- a/model/user.go +++ b/model/user.go @@ -10,8 +10,8 @@ import ( // @Description User is the model representing a user in the system. type User struct { UserID uuid.UUID `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" description:"UUID"` - Username string `json:"username" example:"johndoe" description:"The username of the user"` - IsAnonymous bool `gorm:"default:false" description:"Is the user anonymous"` + Username string `gorm:"unqiue;not null" json:"username" example:"johndoe" description:"The username of the user"` + Password string `gorm:"not null;" json:"password" description:"The password of the user"` IsActive bool `gorm:"default:true" description:"Is the user active"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/repo/code_snippet.go b/repo/code_snippet.go new file mode 100644 index 0000000..dbb0832 --- /dev/null +++ b/repo/code_snippet.go @@ -0,0 +1,24 @@ +package repo + +import ( + "github.com/abinba/codereview/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type CodeSnippetRepository struct { + db *gorm.DB +} + +func NewCodeSnippetRepository(db *gorm.DB) *CodeSnippetRepository { + return &CodeSnippetRepository{db: db} +} + +func (repo *CodeSnippetRepository) CreateCodeSnippet(user_id *uuid.UUID, title string, is_private *bool) error { + user := model.CodeSnippet{ + UserID: user_id, + Title: title, + IsPrivate: is_private, + } + return repo.db.Create(&user).Error +} \ No newline at end of file diff --git a/repo/user_repo.go b/repo/user_repo.go new file mode 100644 index 0000000..e8f9ce4 --- /dev/null +++ b/repo/user_repo.go @@ -0,0 +1,22 @@ +package repo + +import ( + "github.com/abinba/codereview/model" + "gorm.io/gorm" +) + +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (repo *UserRepository) CreateUser(username, password string) error { + user := model.User{ + Username: username, + Password: password, + } + return repo.db.Create(&user).Error +} \ No newline at end of file diff --git a/router/router.go b/router/router.go index 73ec6d4..794143f 100644 --- a/router/router.go +++ b/router/router.go @@ -1,31 +1,47 @@ package router import ( + "github.com/abinba/codereview/middleware" "github.com/abinba/codereview/handler" "github.com/gofiber/fiber/v2" ) func SetupRoutes(app *fiber.App) { + app.Use(middleware.Security) + api := app.Group("/api") v1 := api.Group("/v1") - user := v1.Group("/user") + // Login and registration + v1.Post("/register", handler.CreateUser) + v1.Post("/login", handler.Login) - user.Get("/", handler.GetAllUsers) - user.Get("/:id", handler.GetSingleUser) - user.Post("/", handler.CreateUser) - user.Put("/:id", handler.UpdateUser) - user.Delete("/:id", handler.DeleteUserByID) + // Users + user := v1.Group("/user") + user.Get("/:id", middleware.JWTProtected(), handler.GetSingleUser) + user.Put("/:id", middleware.JWTProtected(), handler.UpdateUser) + user.Delete("/:id", middleware.JWTProtected(), handler.DeleteUserByID) + // Code snippet code_snippet := v1.Group("/code_snippet") - code_snippet.Get("/", handler.GetAllCodeSnippets) code_snippet.Get("/:id", handler.GetSingleCodeSnippet) code_snippet.Post("/", handler.CreateCodeSnippet) - code_snippet.Delete("/:id", handler.DeleteCodeSnippetByID) + + // Versions of the code snippet + code_snippet_version := v1.Group("/code_snippet_version") + code_snippet_version.Post("/", handler.CreateCodeSnippetVersion) - program_language := v1.Group("/program_language") + // User code snippets + user_code_snippets := v1.Group("/user_code_snippet") + user_code_snippets.Get("/:id", middleware.JWTProtected(), handler.GetUserCodeSnippets) + + // Review comments + review_comment := v1.Group("/review_comment") + review_comment.Post("/", handler.CreateReviewComment) + // Programming languages + program_language := v1.Group("/program_language") program_language.Get("/", handler.GetAllProgramLanguages) program_language.Post("/", handler.CreateProgramLanguage) }