diff --git a/cmd/cgnolink/main.go b/cmd/cgnolink/main.go index b11807f..45ce28a 100644 --- a/cmd/cgnolink/main.go +++ b/cmd/cgnolink/main.go @@ -1,20 +1,26 @@ package main import ( - "cgnolink" - "cgnolink/database" + "cgnolink/pkg/cgnolink" + "cgnolink/pkg/cgnolink/database" + appserver "cgnolink/pkg/cgnolink/server" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "os" ) func main() { + configureLogger() + conf := cgnolink.NewConfig() - pool := database.Pool(conf) + pool := database.InitPool(conf) - if err := cgnolink.Migrate(pool); err != nil { - panic(err) - } - - server := cgnolink.NewServer(conf, pool) + server := appserver.NewServer(conf, pool) server.Logger.Fatal(server.Start(":8080")) } + +func configureLogger() { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "2006-01-02 15:04:05"}) +} diff --git a/err/err.go b/err/err.go deleted file mode 100644 index 3d61cb7..0000000 --- a/err/err.go +++ /dev/null @@ -1,47 +0,0 @@ -package err - -import ( - "github.com/labstack/echo/v4" - "net/http" -) - -type NotFoundError struct { - Message string -} - -func (err NotFoundError) Error() string { - return err.Message -} - -type UnknownError struct { - Message string -} - -func (err UnknownError) Error() string { - return err.Message -} - -type AlreadyExistsError struct { - Message string -} - -func (err AlreadyExistsError) Error() string { - return err.Message -} - -func MapErrToHTTPErr(err interface{}) *echo.HTTPError { - switch v := err.(type) { - case NotFoundError: - return echo.NewHTTPError(http.StatusNotFound, v.Message) - case UnknownError: - if v.Message != "" { - return echo.NewHTTPError(http.StatusInternalServerError, v.Message) - } else { - return echo.NewHTTPError(http.StatusInternalServerError) - } - case AlreadyExistsError: - return echo.NewHTTPError(http.StatusBadRequest, v.Message) - default: - return nil - } -} diff --git a/go.mod b/go.mod index 3280081..96d31a5 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,9 @@ require ( github.com/knadh/koanf v0.15.0 github.com/labstack/echo/v4 v4.2.1 github.com/mitchellh/copystructure v1.1.1 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/rs/zerolog v1.20.0 + golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 // indirect golang.org/x/text v0.3.5 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect diff --git a/go.sum b/go.sum index 54d7f00..8c488c5 100644 --- a/go.sum +++ b/go.sum @@ -212,6 +212,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= @@ -237,6 +239,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs= +github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= @@ -363,6 +367,7 @@ golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/link/handlers.go b/link/handlers.go deleted file mode 100644 index 6403f76..0000000 --- a/link/handlers.go +++ /dev/null @@ -1,128 +0,0 @@ -package link - -import ( - . "cgnolink/err" - "encoding/json" - "github.com/jackc/pgx/v4/pgxpool" - "github.com/labstack/echo/v4" - "net/http" - "strconv" -) - -func redirectHandler(c echo.Context, pool *pgxpool.Pool) error { - linkId := c.Param("id") - - serv := PgService{rep: PgRepository{pool: pool}} - - link, err := serv.GetById(linkId) - if err != nil { - return MapErrToHTTPErr(err) - } - - return c.Redirect(http.StatusSeeOther, link.RedirectURL.String()) -} - -func creationHandler(c echo.Context, pool *pgxpool.Pool) error { - var model CreationModel - if err := json.NewDecoder(c.Request().Body).Decode(&model); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid data format.") - } - - entity, err := model.MapModelToEntity() - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid URL.") - } - - serv := PgService{rep: PgRepository{pool: pool}} - - if err = serv.Create(entity); err != nil { - return MapErrToHTTPErr(err) - } - - return c.NoContent(http.StatusCreated) -} - -func retrievalByIdHandler(c echo.Context, pool *pgxpool.Pool) error { - linkId := c.Param("id") - - serv := PgService{rep: PgRepository{pool: pool}} - - l, err := serv.GetById(linkId) - if err != nil { - return MapErrToHTTPErr(err) - } - - return c.JSON(http.StatusOK, MapEntityToModel(l)) -} - -func allRetrievalHandler(c echo.Context, pool *pgxpool.Pool) error { - limit := 20 - if v := c.QueryParam("limit"); v != "" { - num, err := strconv.Atoi(v) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid limit value.") - } - - limit = num - } - - offset := 0 - if v := c.QueryParam("offset"); v != "" { - num, err := strconv.Atoi(v) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid offset value.") - } - - offset = num - } - - serv := PgService{rep: PgRepository{pool: pool}} - - links, err := serv.GetAll(limit, offset) - if err != nil { - return MapErrToHTTPErr(err) - } - - models := make([]ResourceModel, len(links)) - for i, v := range links { - models[i] = MapEntityToModel(v) - } - - return c.JSON(http.StatusOK, models) -} - -func removalHandler(c echo.Context, pool *pgxpool.Pool) error { - linkId := c.Param("id") - - serv := PgService{rep: PgRepository{pool: pool}} - - if err := serv.DeleteById(linkId); err != nil { - return MapErrToHTTPErr(err) - } - - return c.NoContent(http.StatusNoContent) -} - -func AddHandlers(s *echo.Echo, pool *pgxpool.Pool) { - gr := s.Group("/links") - - gr.POST("", func(ctx echo.Context) error { - return creationHandler(ctx, pool) - }) - - gr.GET("", func(ctx echo.Context) error { - return allRetrievalHandler(ctx, pool) - }) - - gr.GET("/:id", func(ctx echo.Context) error { - return retrievalByIdHandler(ctx, pool) - }) - - gr.DELETE("/:id", func(ctx echo.Context) error { - return removalHandler(ctx, pool) - }) - - s.GET("/:id", func(ctx echo.Context) error { - return redirectHandler(ctx, pool) - }) -} diff --git a/link/service.go b/link/service.go deleted file mode 100644 index 34e5b01..0000000 --- a/link/service.go +++ /dev/null @@ -1,63 +0,0 @@ -package link - -import ( - . "cgnolink/err" -) - -type Service interface { - Create(link *Link) error - GetById(id string) (*Link, error) - GetAll(limit int, offset int) (Links, error) - DeleteById(id string) error -} - -type PgService struct { - rep PgRepository -} - -func (s *PgService) Create(link *Link) error { - existingLink, err := s.rep.FindById(link.Id) - if err != nil { - return UnknownError{} - } - - if existingLink != nil { - return AlreadyExistsError{Message: "Link with given ID already exists."} - } - - if err = s.rep.Save(link); err != nil { - return UnknownError{} - } - - return nil -} - -func (s *PgService) GetById(id string) (*Link, error) { - link, err := s.rep.FindById(id) - if err != nil { - return nil, UnknownError{} - } - - if link == nil { - return nil, NotFoundError{Message: "Link with given ID was not found."} - } - - return link, nil -} - -func (s *PgService) GetAll(limit int, offset int) (Links, error) { - links, err := s.rep.FindAll(limit, offset) - if err != nil { - return nil, UnknownError{} - } - - return links, nil -} - -func (s *PgService) DeleteById(id string) error { - if err := s.rep.DeleteById(id); err != nil { - return UnknownError{} - } - - return nil -} diff --git a/migrate.go b/migrate.go deleted file mode 100644 index 4f62eea..0000000 --- a/migrate.go +++ /dev/null @@ -1,29 +0,0 @@ -package cgnolink - -import ( - "context" - "github.com/jackc/pgx/v4/pgxpool" - "github.com/jackc/tern/migrate" -) - -func Migrate(pool *pgxpool.Pool) error { - conn, err := pool.Acquire(context.Background()) - if err != nil { - return err - } - - migrator, err := migrate.NewMigrator(context.Background(), conn.Conn(), "schema_version") - if err != nil { - return err - } - - if err = migrator.LoadMigrations("./migrations"); err != nil { - return err - } - - if err = migrator.Migrate(context.Background()); err != nil { - return err - } - - return nil -} diff --git a/migrations/002_add_password.sql b/migrations/002_add_password.sql new file mode 100644 index 0000000..715fffb --- /dev/null +++ b/migrations/002_add_password.sql @@ -0,0 +1,13 @@ +ALTER TABLE links +ADD COLUMN password varchar; + +UPDATE links +SET password = ''; + +ALTER TABLE links +ALTER COLUMN password SET not null; + +---- create above / drop below ---- + +ALTER TABLE links +DROP COLUMN password; diff --git a/config.go b/pkg/cgnolink/config.go similarity index 100% rename from config.go rename to pkg/cgnolink/config.go diff --git a/database/database.go b/pkg/cgnolink/database/database.go similarity index 60% rename from database/database.go rename to pkg/cgnolink/database/database.go index a6314fb..5796452 100644 --- a/database/database.go +++ b/pkg/cgnolink/database/database.go @@ -6,16 +6,34 @@ import ( "fmt" "github.com/jackc/pgx/v4/pgxpool" "github.com/knadh/koanf" + "github.com/rs/zerolog/log" + "time" ) -func Pool(config *koanf.Koanf) *pgxpool.Pool { - connStr, err := getConnectionString(config) - if err != nil { +func InitPool(config *koanf.Koanf) *pgxpool.Pool { + pool := NewPool(config) + + if err := runMigrations(pool); err != nil { + log.Fatal().Err(err).Msg("Couldn't apply migrations") panic(err) } - pool, err := pgxpool.Connect(context.Background(), connStr) + return pool +} + +func NewPool(config *koanf.Koanf) *pgxpool.Pool { + connStr, err := getConnectionString(config) if err != nil { + log.Fatal().Err(err).Msg("Couldn't construct DB connection string") + panic(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + pool, err := pgxpool.Connect(ctx, connStr) + if err != nil { + log.Fatal().Err(err).Msg("Couldn't connect to DB") panic(err) } diff --git a/pkg/cgnolink/database/migrate.go b/pkg/cgnolink/database/migrate.go new file mode 100644 index 0000000..44f96be --- /dev/null +++ b/pkg/cgnolink/database/migrate.go @@ -0,0 +1,33 @@ +package database + +import ( + "context" + "github.com/jackc/pgx/v4/pgxpool" + "github.com/jackc/tern/migrate" + "time" +) + +func runMigrations(pool *pgxpool.Pool) (err error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + conn, err := pool.Acquire(ctx) + if err != nil { + return + } + + migrator, err := migrate.NewMigrator(ctx, conn.Conn(), "schema_version") + if err != nil { + return + } + + if err = migrator.LoadMigrations("./migrations"); err != nil { + return + } + + if err = migrator.Migrate(ctx); err != nil { + return + } + + return +} diff --git a/pkg/cgnolink/database/util.go b/pkg/cgnolink/database/util.go new file mode 100644 index 0000000..9d31c75 --- /dev/null +++ b/pkg/cgnolink/database/util.go @@ -0,0 +1,15 @@ +package database + +import ( + "github.com/jackc/pgx/v4/pgxpool" + "github.com/rs/zerolog/log" +) + +func LogPoolState(pool *pgxpool.Pool, message string) { + stat := pool.Stat() + log.Info(). + Int32("acquired_conns", stat.AcquiredConns()). + Int32("idle_conns", stat.IdleConns()). + Int32("total_conns", stat.TotalConns()). + Msg(message) +} diff --git a/pkg/cgnolink/errors/errors.go b/pkg/cgnolink/errors/errors.go new file mode 100644 index 0000000..9f1fcf6 --- /dev/null +++ b/pkg/cgnolink/errors/errors.go @@ -0,0 +1,29 @@ +package errors + +type UnknownError struct { + Err error +} + +func (err UnknownError) Error() string { + return err.Err.Error() +} + +func (err UnknownError) Unwrap() error { + return err.Err +} + +type NotFoundError struct { + Message string +} + +func (err NotFoundError) Error() string { + return err.Message +} + +type AlreadyExistsError struct { + Message string +} + +func (err AlreadyExistsError) Error() string { + return err.Message +} diff --git a/link/dto.go b/pkg/cgnolink/link/dto.go similarity index 78% rename from link/dto.go rename to pkg/cgnolink/link/dto.go index 204aa01..c811153 100644 --- a/link/dto.go +++ b/pkg/cgnolink/link/dto.go @@ -9,6 +9,7 @@ type CreationModel struct { Id string `json:"id"` Name string `json:"name"` RedirectURL string `json:"redirectUrl"` + Password string `json:"password"` } type ResourceModel struct { @@ -18,6 +19,12 @@ type ResourceModel struct { CreationTime int64 `json:"creationTime"` } +type UpdateModel struct { + Name string `json:"name,omitempty"` + RedirectURL string `json:"redirectUrl,omitempty"` + Password string `json:"password"` +} + func (m *CreationModel) MapModelToEntity() (*Link, error) { u, err := url.Parse(m.RedirectURL) if err != nil { @@ -28,6 +35,7 @@ func (m *CreationModel) MapModelToEntity() (*Link, error) { Id: m.Id, Name: m.Name, RedirectURL: *u, + Password: m.Password, CreationTime: time.Now().UTC(), }, nil } diff --git a/link/entity.go b/pkg/cgnolink/link/entity.go similarity index 89% rename from link/entity.go rename to pkg/cgnolink/link/entity.go index 50f6521..343743e 100644 --- a/link/entity.go +++ b/pkg/cgnolink/link/entity.go @@ -9,6 +9,7 @@ type Link struct { Id string Name string RedirectURL url.URL + Password string CreationTime time.Time } diff --git a/pkg/cgnolink/link/handlers.go b/pkg/cgnolink/link/handlers.go new file mode 100644 index 0000000..10a25c6 --- /dev/null +++ b/pkg/cgnolink/link/handlers.go @@ -0,0 +1,151 @@ +package link + +import ( + "encoding/json" + "github.com/jackc/pgx/v4/pgxpool" + "github.com/labstack/echo/v4" + "net/http" + "net/url" + "strconv" +) + +func redirectHandler(ctx echo.Context, serv Service) error { + linkId := ctx.Param("id") + linkPassword := ctx.QueryParam("password") + + redirectUrl, err := serv.AccessLink(linkId, linkPassword) + if err != nil { + return err + } + + return ctx.Redirect(http.StatusSeeOther, redirectUrl.String()) +} + +func creationHandler(ctx echo.Context, serv Service) error { + var model CreationModel + if err := json.NewDecoder(ctx.Request().Body).Decode(&model); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid data format.") + } + + entity, err := model.MapModelToEntity() + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid URL.") + } + + if err = serv.Create(entity); err != nil { + return err + } + + return ctx.NoContent(http.StatusCreated) +} + +func retrievalByIdHandler(ctx echo.Context, serv Service) error { + linkId := ctx.Param("id") + + l, err := serv.GetById(linkId) + if err != nil { + return err + } + + return ctx.JSON(http.StatusOK, MapEntityToModel(l)) +} + +func allRetrievalHandler(ctx echo.Context, serv Service) error { + limit := 20 + if v := ctx.QueryParam("limit"); v != "" { + num, err := strconv.Atoi(v) + if err != nil || num < 0 { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid limit value.") + } + + limit = num + } + + offset := 0 + if v := ctx.QueryParam("offset"); v != "" { + num, err := strconv.Atoi(v) + if err != nil || num < 0 { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid offset value.") + } + + offset = num + } + + links, err := serv.GetAll(limit, offset) + if err != nil { + return err + } + + models := make([]ResourceModel, len(links)) + for i, v := range links { + models[i] = MapEntityToModel(v) + } + + return ctx.JSON(http.StatusOK, models) +} + +func updateHandler(ctx echo.Context, serv Service) error { + linkId := ctx.Param("id") + + var model UpdateModel + if err := json.NewDecoder(ctx.Request().Body).Decode(&model); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid data format.") + } + + var parsedUrl *url.URL + parsedUrl, err := url.Parse(model.RedirectURL) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid URL value.") + } + + if err := serv.UpdateById(linkId, struct { + Name string + Password string + RedirectURL *url.URL + }{Name: model.Name, Password: model.Password, RedirectURL: parsedUrl}); err != nil { + return err + } + + return ctx.NoContent(http.StatusOK) +} + +func removalHandler(ctx echo.Context, serv Service) error { + linkId := ctx.Param("id") + + if err := serv.DeleteById(linkId); err != nil { + return err + } + + return ctx.NoContent(http.StatusNoContent) +} + +func AddHandlers(server *echo.Echo, pool *pgxpool.Pool) { + serv := NewService(NewRepository(pool)) + + linksGroup := server.Group("/links") + exactLinkGroup := linksGroup.Group("/:id") + + linksGroup.POST("", func(ctx echo.Context) error { + return creationHandler(ctx, serv) + }) + + linksGroup.GET("", func(ctx echo.Context) error { + return allRetrievalHandler(ctx, serv) + }) + + exactLinkGroup.GET("", func(ctx echo.Context) error { + return retrievalByIdHandler(ctx, serv) + }) + + exactLinkGroup.PATCH("", func(ctx echo.Context) error { + return updateHandler(ctx, serv) + }) + + exactLinkGroup.DELETE("", func(ctx echo.Context) error { + return removalHandler(ctx, serv) + }) + + server.GET("/:id", func(ctx echo.Context) error { + return redirectHandler(ctx, serv) + }) +} diff --git a/link/repository.go b/pkg/cgnolink/link/repository.go similarity index 56% rename from link/repository.go rename to pkg/cgnolink/link/repository.go index f07792e..aa19b82 100644 --- a/link/repository.go +++ b/pkg/cgnolink/link/repository.go @@ -1,6 +1,7 @@ package link import ( + "cgnolink/pkg/cgnolink/database" "context" "errors" "github.com/jackc/pgtype" @@ -10,12 +11,13 @@ import ( "time" ) -const defaultContextTimeout = 5 * time.Second +const defaultContextTimeout = 10 * time.Second type Repository interface { Save(link *Link) error FindById(id string) (*Link, error) - FindAll(limit int, offset int) (Links, error) + GetAll(limit int, offset int) (Links, error) + Update(link *Link) error DeleteById(id string) error } @@ -23,27 +25,22 @@ type PgRepository struct { pool *pgxpool.Pool } +func NewRepository(pool *pgxpool.Pool) Repository { + return &PgRepository{pool: pool} +} + func (r *PgRepository) Save(link *Link) error { ctx, cancel := context.WithTimeout(context.Background(), defaultContextTimeout) defer cancel() - tx, err := r.pool.Begin(ctx) - if err != nil { - return err - } - sql := ` - INSERT INTO links (id, name, redirect_url, creation_time) - VALUES ($1, $2, $3, $4::timestamp) + INSERT INTO links (id, name, redirect_url, password, creation_time) + VALUES ($1, $2, $3, $4, $5::timestamp) ` - _, err = tx.Exec(ctx, sql, link.Id, link.Name, link.RedirectURL.String(), link.CreationTime.Format("2006-01-02 15:04:05")) + database.LogPoolState(r.pool, "Saving link") + _, err := r.pool.Exec(ctx, sql, link.Id, link.Name, link.RedirectURL.String(), link.Password, link.CreationTime.Format("2006-01-02 15:04:05")) if err != nil { - _ = tx.Rollback(ctx) - return err - } - - if err = tx.Commit(ctx); err != nil { return err } @@ -54,35 +51,26 @@ func (r *PgRepository) FindById(id string) (*Link, error) { ctx, cancel := context.WithTimeout(context.Background(), defaultContextTimeout) defer cancel() - tx, err := r.pool.Begin(ctx) - if err != nil { - return nil, err - } - sql := ` - SELECT id, name, redirect_url, creation_time + SELECT id, name, redirect_url, password, creation_time FROM links WHERE id = $1 ` - entity, err := mapRowToEntity(tx.QueryRow(ctx, sql, id)) + database.LogPoolState(r.pool, "Finding link by ID") + entity, err := mapRowToEntity(r.pool.QueryRow(ctx, sql, id)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } else { - _ = tx.Rollback(ctx) return nil, err } } - if err = tx.Commit(ctx); err != nil { - return nil, err - } - return entity, nil } -func (r *PgRepository) FindAll(limit int, offset int) (Links, error) { +func (r *PgRepository) GetAll(limit int, offset int) (Links, error) { if limit < 0 { return nil, errors.New("limit can't be negative") } @@ -93,21 +81,16 @@ func (r *PgRepository) FindAll(limit int, offset int) (Links, error) { ctx, cancel := context.WithTimeout(context.Background(), defaultContextTimeout) defer cancel() - tx, err := r.pool.Begin(ctx) - if err != nil { - return nil, err - } - sql := ` - SELECT id, name, redirect_url, creation_time + SELECT id, name, redirect_url, password, creation_time FROM links LIMIT $1 OFFSET $2 ` - rows, err := tx.Query(ctx, sql, limit, offset) + database.LogPoolState(r.pool, "Getting all links") + rows, err := r.pool.Query(ctx, sql, limit, offset) if err != nil { - _ = tx.Rollback(ctx) return nil, err } defer rows.Close() @@ -116,7 +99,6 @@ func (r *PgRepository) FindAll(limit int, offset int) (Links, error) { for rows.Next() { link, err := mapRowToEntity(rows) if err != nil { - _ = tx.Rollback(ctx) return nil, err } @@ -124,37 +106,46 @@ func (r *PgRepository) FindAll(limit int, offset int) (Links, error) { } if err = rows.Err(); err != nil { - _ = tx.Rollback(ctx) - return nil, err - } - - if err = tx.Commit(ctx); err != nil { return nil, err } return links, nil } -func (r *PgRepository) DeleteById(id string) error { +func (r *PgRepository) Update(link *Link) error { + if link.Id == "" { + return errors.New("link ID must not be empty") + } + ctx, cancel := context.WithTimeout(context.Background(), defaultContextTimeout) defer cancel() - tx, err := r.pool.Begin(ctx) + sql := ` + UPDATE links + SET name = $1, redirect_url = $2, password = $3 + WHERE id = $4 + ` + + database.LogPoolState(r.pool, "Updating link") + _, err := r.pool.Exec(ctx, sql, link.Name, link.RedirectURL.String(), link.Password, link.Id) if err != nil { return err } + return nil +} + +func (r *PgRepository) DeleteById(id string) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultContextTimeout) + defer cancel() + sql := ` DELETE FROM links WHERE id = $1 ` - _, err = tx.Exec(ctx, sql, id) + database.LogPoolState(r.pool, "Deleting link") + _, err := r.pool.Exec(ctx, sql, id) if err != nil { - _ = tx.Rollback(ctx) - return err - } - - if err = tx.Commit(ctx); err != nil { return err } @@ -168,11 +159,11 @@ func mapRowToEntity(r interface{}) (*Link, error) { switch v := r.(type) { case pgx.Row: - if err := v.Scan(&entity.Id, &entity.Name, &urlStr, &t); err != nil { + if err := v.Scan(&entity.Id, &entity.Name, &urlStr, &entity.Password, &t); err != nil { return nil, err } case pgx.Rows: - if err := v.Scan(&entity.Id, &entity.Name, &urlStr, &t); err != nil { + if err := v.Scan(&entity.Id, &entity.Name, &urlStr, &entity.Password, &t); err != nil { return nil, err } default: diff --git a/pkg/cgnolink/link/service.go b/pkg/cgnolink/link/service.go new file mode 100644 index 0000000..b7f00f6 --- /dev/null +++ b/pkg/cgnolink/link/service.go @@ -0,0 +1,171 @@ +package link + +import ( + apperrors "cgnolink/pkg/cgnolink/errors" + "github.com/patrickmn/go-cache" + "golang.org/x/crypto/bcrypt" + "net/url" +) + +var linkNotFoundError = apperrors.NotFoundError{Message: "Link with given ID was not found."} + +type Service interface { + Create(link *Link) error + GetById(id string) (*Link, error) + AccessLink(id string, password string) (*url.URL, error) + GetAll(limit int, offset int) (Links, error) + UpdateById( + id string, + data struct { + Name string + Password string + RedirectURL *url.URL + }, + ) error + DeleteById(id string) error +} + +type PgService struct { + rep Repository + cache *cache.Cache +} + +func (service *PgService) AccessLink(id string, password string) (*url.URL, error) { + link, err := service.GetById(id) + if err != nil { + return nil, err + } + + if link.Password != "" { + if password == "" || bcrypt.CompareHashAndPassword([]byte(link.Password), []byte(password)) != nil { + return nil, linkNotFoundError + } + } + + return &link.RedirectURL, nil +} + +func NewService(rep Repository) Service { + return &PgService{ + rep: rep, + cache: cache.New(cache.NoExpiration, 0), + } +} + +func (service *PgService) Create(link *Link) error { + existingLink, err := service.rep.FindById(link.Id) + if err != nil { + return apperrors.UnknownError{Err: err} + } + + if existingLink != nil { + return apperrors.AlreadyExistsError{Message: "Link with given ID already exists."} + } + + if link.Password != "" { + hashedPassword, err := HashPassword(link.Password) + if err != nil { + return err + } + + link.Password = hashedPassword + } + + if err = service.rep.Save(link); err != nil { + return apperrors.UnknownError{Err: err} + } + + return nil +} + +func (service *PgService) GetById(id string) (*Link, error) { + if v, found := service.cache.Get(id); found { + if link, ok := v.(*Link); ok { + return link, nil + } + } + + link, err := service.rep.FindById(id) + if err != nil { + return nil, apperrors.UnknownError{Err: err} + } + + if link == nil { + return nil, linkNotFoundError + } + + service.cache.Set(id, link, cache.DefaultExpiration) + + return link, nil +} + +func (service *PgService) GetAll(limit int, offset int) (Links, error) { + links, err := service.rep.GetAll(limit, offset) + if err != nil { + return nil, apperrors.UnknownError{Err: err} + } + + return links, nil +} + +func (service *PgService) UpdateById( + id string, + data struct { + Name string + Password string + RedirectURL *url.URL + }, +) error { + link, err := service.GetById(id) + if err != nil { + return err + } + + hasChanges := false + switch { + case data.Name != "": + link.Name = data.Name + + hasChanges = true + case data.RedirectURL != nil: + link.RedirectURL = *data.RedirectURL + + hasChanges = true + case data.Password != "": + hashedPw, err := HashPassword(data.Password) + if err != nil { + return err + } + + link.Password = hashedPw + + hasChanges = true + } + + if hasChanges { + if err := service.rep.Update(link); err != nil { + return apperrors.UnknownError{Err: err} + } + service.cache.Delete(link.Id) + } + + return nil +} + +func (service *PgService) DeleteById(id string) error { + if err := service.rep.DeleteById(id); err != nil { + return apperrors.UnknownError{Err: err} + } + service.cache.Delete(id) + + return nil +} + +func HashPassword(password string) (string, error) { + hashedPw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", apperrors.UnknownError{Err: err} + } + + return string(hashedPw), nil +} diff --git a/pkg/cgnolink/server/middleware.go b/pkg/cgnolink/server/middleware.go new file mode 100644 index 0000000..82c2901 --- /dev/null +++ b/pkg/cgnolink/server/middleware.go @@ -0,0 +1,34 @@ +package server + +import ( + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "time" +) + +func Logger() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) (err error) { + req := c.Request() + res := c.Response() + start := time.Now() + if err = next(c); err != nil { + c.Error(err) + } + stop := time.Now() + + log.Info(). + Str("remote_ip", c.RealIP()). + Str("host", req.Host). + Str("uri", req.RequestURI). + Str("method", req.Method). + Str("path", req.URL.Path). + Str("user_agent", req.UserAgent()). + Int("status", res.Status). + Str("latency", stop.Sub(start).String()). + Msg("Request") + + return + } + } +} diff --git a/server.go b/pkg/cgnolink/server/server.go similarity index 52% rename from server.go rename to pkg/cgnolink/server/server.go index c62ea7c..254116b 100644 --- a/server.go +++ b/pkg/cgnolink/server/server.go @@ -1,17 +1,22 @@ -package cgnolink +package server import ( - "cgnolink/link" + apperrors "cgnolink/pkg/cgnolink/errors" + "cgnolink/pkg/cgnolink/link" "github.com/jackc/pgx/v4/pgxpool" "github.com/knadh/koanf" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "github.com/rs/zerolog/log" "net/http" + "regexp" ) func NewServer(conf *koanf.Koanf, pool *pgxpool.Pool) *echo.Echo { server := echo.New() + server.HTTPErrorHandler = errorHandler(server) + addMiddleware(server, conf) addHandlers(server, pool) @@ -21,11 +26,13 @@ func NewServer(conf *koanf.Koanf, pool *pgxpool.Pool) *echo.Echo { func addMiddleware(s *echo.Echo, conf *koanf.Koanf) { s.Use(middleware.CORS()) - s.Use(middleware.Logger()) + s.Use(Logger()) s.Use(middleware.JWTWithConfig(middleware.JWTConfig{ Skipper: func(ctx echo.Context) bool { - return ctx.Request().Method == "GET" && ctx.Request().URL.Path != "/links" + matchesLinks, _ := regexp.MatchString("^/links", ctx.Request().URL.Path) + + return ctx.Request().Method == "GET" && !matchesLinks }, SigningMethod: conf.String("jwt.method"), SigningKey: conf.Bytes("jwt.key"), @@ -43,3 +50,21 @@ func addHandlers(s *echo.Echo, pool *pgxpool.Pool) { link.AddHandlers(s, pool) } + +func errorHandler(s *echo.Echo) echo.HTTPErrorHandler { + return func(err error, c echo.Context) { + mappedErr := err + + switch v := err.(type) { + case apperrors.NotFoundError: + mappedErr = echo.NewHTTPError(http.StatusNotFound, v.Error()) + case apperrors.UnknownError: + log.Err(v.Err).Send() + mappedErr = echo.NewHTTPError(http.StatusInternalServerError) + case apperrors.AlreadyExistsError: + mappedErr = echo.NewHTTPError(http.StatusBadRequest, v.Error()) + } + + s.DefaultHTTPErrorHandler(mappedErr, c) + } +}