diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..cafc581 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,44 @@ +image: docker:20.10.16 + +variables: + # When using dind service, you must instruct Docker to talk with + # the daemon started inside of the service. The daemon is available + # with a network connection instead of the default + # /var/run/docker.sock socket. + DOCKER_HOST: tcp://docker:2376 + # + # The 'docker' hostname is the alias of the service container as described at + # https://docs.gitlab.com/ee/ci/services/#accessing-the-services. + # If you're using GitLab Runner 12.7 or earlier with the Kubernetes executor and Kubernetes 1.6 or earlier, + # the variable must be set to tcp://localhost:2376 because of how the + # Kubernetes executor connects services to the job container + # DOCKER_HOST: tcp://localhost:2376 + # + # Specify to Docker where to create the certificates. Docker + # creates them automatically on boot, and creates + # `/certs/client` to share between the service and job + # container, thanks to volume mount from config.toml + DOCKER_TLS_CERTDIR: "/certs" + # These are usually specified by the entrypoint, however the + # Kubernetes executor doesn't run entrypoints + # https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4125 + DOCKER_TLS_VERIFY: 1 + DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client" + +services: + - docker:20.10.16-dind + +stages: + - build + +build-job: + stage: build + tags: + - docker + only: + - master + script: + - docker info + - docker build -t docker-registry.dopcore.com/andrusyakka/astra-users . + - echo "$DOCKER_PASSWORD" | docker login docker-registry.dopcore.com -u "$DOCKER_USER" --password-stdin + - docker push docker-registry.dopcore.com/andrusyakka/astra-users diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ddd2eb1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM kandrusyak/go-build as build +LABEL authors="andrusyakka@dopcore.com" + +WORKDIR /app + +ENV CGO_ENABLED 1 + +COPY go.mod ./ + +RUN go mod download + +COPY . ./ + +RUN go build -o ./app + +FROM alpine:edge + +WORKDIR / + +COPY --from=build /app /app + +EXPOSE 8080 + +CMD ["/app/app"] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1c9cfd1 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module astra-users + +go 1.21rc1 + +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/labstack/echo/v4 v4.10.2 // indirect + github.com/labstack/gommon v0.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.6.0 // indirect + golang.org/x/net v0.7.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect + golang.org/x/time v0.3.0 // indirect + gorm.io/driver/sqlite v1.5.2 // indirect + gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2aa2d89 --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M= +github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k= +github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= +github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc= +gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= +gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64= +gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55 h1:sC1Xj4TYrLqg1n3AN10w871An7wJM0gzgcm8jkIkECQ= +gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= diff --git a/handler/auth.go b/handler/auth.go new file mode 100644 index 0000000..92434dc --- /dev/null +++ b/handler/auth.go @@ -0,0 +1,29 @@ +package handler + +import ( + "astra-users/model" + "crypto/sha256" + "fmt" + "github.com/labstack/echo/v4" + "net/http" +) + +func (h *Handler) Login(c echo.Context) (err error) { + // Bind + u := new(model.User) + if err = c.Bind(u); err != nil { + return + } + + u.Password = fmt.Sprintf("%x", sha256.Sum256([]byte(u.Password))) + + var t = h.DB.Where("user_name = ? AND password = ?", u.UserName, u.Password).First(&u) + + if t.RowsAffected == 0 { + return c.NoContent(http.StatusNotFound) + } + + u.Password = "" + + return c.JSON(http.StatusOK, u) +} diff --git a/handler/handler.go b/handler/handler.go new file mode 100644 index 0000000..83a7019 --- /dev/null +++ b/handler/handler.go @@ -0,0 +1,7 @@ +package handler + +import "gorm.io/gorm" + +type Handler struct { + DB *gorm.DB +} diff --git a/handler/health.go b/handler/health.go new file mode 100644 index 0000000..c1d0429 --- /dev/null +++ b/handler/health.go @@ -0,0 +1,14 @@ +package handler + +import ( + "github.com/labstack/echo/v4" + "net/http" +) + +func (h *Handler) Health(c echo.Context) error { + type Resp struct { + Status string `json:"status"` + } + + return c.JSON(http.StatusOK, Resp{Status: "OK"}) +} diff --git a/handler/users.go b/handler/users.go new file mode 100644 index 0000000..c1f02be --- /dev/null +++ b/handler/users.go @@ -0,0 +1,114 @@ +package handler + +import ( + "astra-users/model" + "astra-users/pkg/utils" + "crypto/sha256" + "fmt" + "github.com/labstack/echo/v4" + "net/http" +) + +func (h *Handler) GetCurrentUser(c echo.Context) error { + userId := c.Request().Header.Get("UserId") + + if userId == "" { + return c.NoContent(http.StatusBadRequest) + } + + u := new(model.User) + + tx := h.DB.First(&u, userId) + + if tx.RowsAffected == 0 { + return c.NoContent(http.StatusNotFound) + } + + u.Password = "" + + return c.JSON(http.StatusOK, &u) +} + +func (h *Handler) GetUsers(c echo.Context) error { + var users []model.User + + query := c.Request().URL.Query() + limit, offset := utils.GetPaginationParams(query) + sortCol, isDesc, filterCol, filterVal := utils.GetFilterParams(query) + + tx := h.DB.Limit(limit).Offset(offset) + + utils.ApplyIdFilter(c, tx) + utils.ApplySort(tx, sortCol, isDesc) + utils.ApplyFilters(tx, filterCol, filterVal) + + tx = tx.Find(&users) + + if tx.RowsAffected == 1 { + return c.JSON(http.StatusOK, users[0]) + } + + return c.JSON(http.StatusOK, users) +} + +func (h *Handler) UpdateUser(c echo.Context) (err error) { + var user = new(model.User) + var apiUser = new(model.APIUser) + tx := h.DB + + id := c.Param("id") + tx = tx.Find(&user, id) + + if tx.RowsAffected == 0 { + return c.NoContent(http.StatusNotFound) + } + + if err = c.Bind(apiUser); err != nil { + return c.NoContent(http.StatusBadRequest) + } + + user.FirstName = apiUser.FirstName + user.LastName = apiUser.LastName + + tx = h.DB.Save(&user) + + if tx.RowsAffected == 0 { + return c.NoContent(http.StatusBadGateway) + } + + user.Password = "" + + return c.JSON(http.StatusOK, &user) +} + +func (h *Handler) DeleteUser(c echo.Context) (err error) { + id := c.Param("id") + + tx := h.DB.Delete(&model.User{}, id) + + if tx.RowsAffected == 0 { + return c.NoContent(http.StatusBadGateway) + } + + return c.NoContent(http.StatusOK) +} + +func (h *Handler) CreateUser(c echo.Context) (err error) { + var user = new(model.User) + + if err = c.Bind(user); err != nil { + return c.NoContent(http.StatusBadRequest) + } + + user.Password = fmt.Sprintf("%x", sha256.Sum256([]byte(user.Password))) + + tx := h.DB.Omit("ID", "CreatedAt", "UpdatedAt", "DeletedAt").Create(user) + + if tx.RowsAffected == 0 { + return c.NoContent(http.StatusBadGateway) + } + + user.Password = "" + + return c.JSON(http.StatusCreated, user) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bdb1625 --- /dev/null +++ b/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "astra-users/handler" + "astra-users/model" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "log" + "os" + "time" +) + +func main() { + newLogger := logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer + logger.Config{ + SlowThreshold: time.Second, // Slow SQL threshold + LogLevel: logger.Info, // Log level + IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger + ParameterizedQueries: true, // Don't include params in the SQL log + Colorful: false, // Disable color + }, + ) + + db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{ + Logger: newLogger, + }) + if err != nil { + panic("failed to connect database") + } + e := echo.New() + + db.AutoMigrate(&model.User{}) + + e.Use(middleware.Logger()) + + var h = &handler.Handler{DB: db} + + e.POST("/login", h.Login) + e.GET("/me", h.GetCurrentUser) + e.POST("/", h.CreateUser) + e.GET("/", h.GetUsers) + e.GET("/:id", h.GetUsers) + e.PATCH("/:id", h.UpdateUser) + e.DELETE("/:id", h.DeleteUser) + e.GET("/health", h.Health) + + e.Logger.Fatal(e.Start(":8081")) +} diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000..9484519 --- /dev/null +++ b/model/user.go @@ -0,0 +1,20 @@ +package model + +import "gorm.io/gorm" + +type ( + User struct { + gorm.Model + UserName string `json:"userName" gorm:"unique"` + Password string `json:"password,omitempty"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + } +) + +type APIUser struct { + UserName string `json:"userName" gorm:"unique"` + Password string `json:"password,omitempty"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` +} diff --git a/pkg/utils/query.go b/pkg/utils/query.go new file mode 100644 index 0000000..eedf5b9 --- /dev/null +++ b/pkg/utils/query.go @@ -0,0 +1,27 @@ +package utils + +import ( + "github.com/labstack/echo/v4" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func ApplySort(tx *gorm.DB, column string, isDesc bool) { + tx.Order(clause.OrderByColumn{ + Column: clause.Column{Name: column}, + Desc: isDesc, + }) +} + +func ApplyFilters(tx *gorm.DB, column string, value string) { + if column != "" && value != "" { + tx.Where(column + " LIKE '%" + value + "%'") + } +} + +func ApplyIdFilter(c echo.Context, tx *gorm.DB) { + id := c.Param("id") + if id != "" { + tx.Where("id = ?", id) + } +} diff --git a/pkg/utils/url.go b/pkg/utils/url.go new file mode 100644 index 0000000..330e69d --- /dev/null +++ b/pkg/utils/url.go @@ -0,0 +1,52 @@ +package utils + +import ( + "net/url" + "strconv" + "strings" +) + +func GetQueryParam(query url.Values, key string, defaultVal string) string { + data := query.Get(key) + + if data == "" { + return defaultVal + } + + return data +} + +func GetPaginationParams(query url.Values) (int, int) { + offset := GetQueryParam(query, "offset", "0") + limit := GetQueryParam(query, "limit", "10") + + limitInt, err := strconv.Atoi(limit) + + if err != nil { + limitInt = 10 + } + + offsetInt, err := strconv.Atoi(offset) + + if err != nil { + offsetInt = 0 + } + + return limitInt, offsetInt +} + +func GetFilterParams(query url.Values) (string, bool, string, string) { + sort := GetQueryParam(query, "sort", "id:asc") + filter := GetQueryParam(query, "filter", ":") + + filters := strings.Split(filter, ":") + sorts := strings.Split(sort, ":") + + isDesc := false + + if sorts[1] == "desc" { + isDesc = true + } + + return sorts[0], isDesc, filters[0], filters[1] +} diff --git a/test.db b/test.db new file mode 100644 index 0000000..882d457 Binary files /dev/null and b/test.db differ