Dima пре 1 месец
родитељ
комит
af2ea89095
36 измењених фајлова са 566 додато и 529 уклоњено
  1. 2 1
      cmd/app/main.go
  2. 1 0
      go.mod
  3. 2 0
      go.sum
  4. 16 0
      init/systemd/dmitriygnatenko.service
  5. 2 2
      internal/dto/article.go
  6. 1 1
      internal/dto/tag.go
  7. 13 33
      internal/fiber/fiber.go
  8. 0 4
      internal/helpers/fiber.go
  9. 1 1
      internal/mapper/article.go
  10. 4 4
      internal/models/article.go
  11. 2 2
      internal/models/tag.go
  12. 1 1
      internal/models/user.go
  13. 56 14
      internal/repositories/article.go
  14. 6 6
      internal/repositories/article_tag.go
  15. 7 7
      internal/repositories/tag.go
  16. 4 4
      internal/repositories/user.go
  17. 74 21
      internal/service_provider/sp.go
  18. 4 4
      internal/services/auth/auth.go
  19. 0 42
      internal/services/cache/cache.go
  20. 0 55
      internal/services/db/db.go
  21. 168 97
      internal/services/env/env.go
  22. 30 22
      internal/services/handler/admin/article.go
  23. 3 3
      internal/services/handler/admin/auth.go
  24. 3 4
      internal/services/handler/admin/tag.go
  25. 13 6
      internal/services/handler/article.go
  26. 22 22
      internal/services/handler/article_test.go
  27. 7 3
      internal/services/handler/main_page.go
  28. 6 3
      internal/services/handler/main_page_test.go
  29. 10 10
      internal/services/handler/mocks/article_repository_minimock.go
  30. 45 16
      internal/services/handler/mocks/cache_service_minimock.go
  31. 15 7
      internal/services/handler/tag.go
  32. 16 13
      internal/services/handler/tag_test.go
  33. 0 90
      internal/services/mailer/mailer.go
  34. 0 12
      internal/templates/_ga.html
  35. 0 3
      internal/templates/_layout.html
  36. 32 16
      readme.md

+ 2 - 1
cmd/app/main.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"fmt"
 	"log"
 
 	_ "github.com/lib/pq"
@@ -20,7 +21,7 @@ func main() {
 		log.Fatal(err)
 	}
 
-	if err = fiberApp.Listen(":" + serviceProvider.EnvService().AppPort()); err != nil {
+	if err = fiberApp.Listen(fmt.Sprintf(":%d", serviceProvider.EnvService().AppPort())); err != nil {
 		log.Fatal(err)
 	}
 }

+ 1 - 0
go.mod

@@ -20,6 +20,7 @@ require (
 )
 
 require (
+	git.dmitriygnatenko.ru/dima/go-common v1.4.0 // indirect
 	github.com/andybalholm/brotli v1.0.4 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/fsnotify/fsnotify v1.6.0 // indirect

+ 2 - 0
go.sum

@@ -49,6 +49,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
 cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+git.dmitriygnatenko.ru/dima/go-common v1.4.0 h1:9e8lWll/qV1VbscyQ7WrKh59oyPubS5YU7WLtPwX5sE=
+git.dmitriygnatenko.ru/dima/go-common v1.4.0/go.mod h1:vs66NhH1j8GnI9iC/viyrwAzDXsqQ8baEV3Rn5tLzQA=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=

+ 16 - 0
init/systemd/dmitriygnatenko.service

@@ -0,0 +1,16 @@
+[Unit]
+Description=dmitriygnatenko.ru service
+After=network.target
+After=nginx.service
+After=postgresql.service
+
+[Service]
+Type=simple
+User=dima
+Group=dima
+ExecStart=/var/www/dmitriygnatenko.ru/build/app/app -config="/var/www/dmitriygnatenko.ru/.env"
+WorkingDirectory=/var/www/dmitriygnatenko.ru/build/app
+Restart=always
+
+[Install]
+WantedBy=multi-user.target

+ 2 - 2
internal/dto/article.go

@@ -1,7 +1,7 @@
 package dto
 
 type Article struct {
-	ID              int
+	ID              uint64
 	URL             string
 	Title           string
 	PublishTime     string
@@ -12,7 +12,7 @@ type Article struct {
 }
 
 type ArticlePreview struct {
-	ID          int
+	ID          uint64
 	URL         string
 	Title       string
 	PublishTime string

+ 1 - 1
internal/dto/tag.go

@@ -1,7 +1,7 @@
 package dto
 
 type Tag struct {
-	ID  int
+	ID  uint64
 	URL string
 	Tag string
 }

+ 13 - 33
internal/fiber/fiber.go

@@ -1,11 +1,12 @@
 package fiber
 
 import (
+	"errors"
 	"html/template"
-	"log"
 	"strconv"
 	"time"
 
+	cacheService "git.dmitriygnatenko.ru/dima/go-common/cache/ttl_memory_cache"
 	"github.com/gofiber/fiber/v2"
 	"github.com/gofiber/fiber/v2/middleware/basicauth"
 	"github.com/gofiber/fiber/v2/middleware/cors"
@@ -17,11 +18,9 @@ import (
 
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/repositories"
 	authService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/auth"
-	cacheService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/cache"
 	envService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/env"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/handler"
 	adminHandler "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/handler/admin"
-	mailService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/mailer"
 )
 
 const (
@@ -36,9 +35,8 @@ const (
 type (
 	ServiceProvider interface {
 		EnvService() *envService.Service
-		MailerService() *mailService.Service
 		AuthService() *authService.Service
-		CacheService() *cacheService.Service
+		CacheService() *cacheService.Cache
 		ArticleRepository() *repositories.ArticleRepository
 		TagRepository() *repositories.TagRepository
 		ArticleTagRepository() *repositories.ArticleTagRepository
@@ -46,8 +44,7 @@ type (
 	}
 
 	EnvService interface {
-		StaticVersion() int
-		GAKey() string
+		StaticVersion() uint64
 	}
 )
 
@@ -134,7 +131,6 @@ func Init(sp ServiceProvider) (*fiber.App, error) {
 			sp.ArticleRepository(),
 			sp.TagRepository(),
 			sp.ArticleTagRepository(),
-			sp.CacheService(),
 		),
 	)
 	admin.All(
@@ -188,15 +184,15 @@ func getConfig(sp ServiceProvider) fiber.Config {
 		AppName:               appName,
 		DisableStartupMessage: true,
 		Views:                 getViewsEngine(sp.EnvService()),
-		ErrorHandler:          getErrorHandler(sp),
+		ErrorHandler:          getErrorHandler(),
 	}
 }
 
 // nolint
 func getJWTConfig(sp ServiceProvider) jwt.Config {
 	return jwt.Config{
-		SigningKey:  []byte(sp.EnvService().JWTSecretKey()),
-		TokenLookup: "cookie:" + sp.EnvService().JWTCookie(),
+		SigningKey:  []byte(sp.EnvService().JwtSecretKey()),
+		TokenLookup: "cookie:" + sp.EnvService().JwtCookie(),
 		ErrorHandler: func(fctx *fiber.Ctx, err error) error {
 			return fctx.Redirect("/admin/login")
 		},
@@ -226,8 +222,8 @@ func getMetricsConfig() monitor.Config {
 
 func getCORSConfig(sp ServiceProvider) cors.Config {
 	return cors.Config{
-		AllowOrigins: sp.EnvService().CORSAllowOrigins(),
-		AllowMethods: sp.EnvService().CORSAllowMethods(),
+		AllowOrigins: sp.EnvService().AppCORSAllowOrigins(),
+		AllowMethods: sp.EnvService().AppCORSAllowMethods(),
 	}
 }
 
@@ -250,36 +246,20 @@ func getViewsEngine(env EnvService) *html.Engine {
 	})
 
 	engine.AddFunc("version", func() string {
-		return strconv.Itoa(env.StaticVersion())
-	})
-
-	engine.AddFunc("ga", func() string {
-		return env.GAKey()
+		return strconv.FormatUint(env.StaticVersion(), 64)
 	})
 
 	return engine
 }
 
-func getErrorHandler(sp ServiceProvider) fiber.ErrorHandler {
+func getErrorHandler() fiber.ErrorHandler {
 	return func(fctx *fiber.Ctx, err error) error {
 		errCode := fiber.StatusInternalServerError
-		if e, ok := err.(*fiber.Error); ok {
+		var e *fiber.Error
+		if errors.As(err, &e) {
 			errCode = e.Code
 		}
 
-		if err.Error() != "" {
-			errorsEmail := sp.EnvService().ErrorsEmail()
-			if errCode == fiber.StatusInternalServerError && errorsEmail != "" {
-				log.Println(err)
-				// nolint
-				sp.MailerService().Send(
-					errorsEmail,
-					"AUTO - dmitriygnatenko.ru error",
-					err.Error(),
-				)
-			}
-		}
-
 		var renderData fiber.Map
 
 		if errCode == fiber.StatusNotFound {

+ 0 - 4
internal/helpers/fiber.go

@@ -32,10 +32,6 @@ func GetFiberTestConfig() fiber.Config {
 		return strconv.Itoa(gofakeit.Number(1, 1000))
 	})
 
-	engine.AddFunc("ga", func() string {
-		return ""
-	})
-
 	return fiber.Config{
 		Views: engine,
 	}

+ 1 - 1
internal/mapper/article.go

@@ -58,7 +58,7 @@ func ToArticlePreviewDTOList(m []models.ArticlePreview) []dto.ArticlePreview {
 }
 
 func ToArticleForm(a models.Article, tags []models.Tag) *models.ArticleForm {
-	tagMap := make(map[int]bool, len(tags))
+	tagMap := make(map[uint64]bool, len(tags))
 	for i := range tags {
 		tagMap[tags[i].ID] = true
 	}

+ 4 - 4
internal/models/article.go

@@ -6,7 +6,7 @@ import (
 )
 
 type ArticlePreview struct {
-	ID          int
+	ID          uint64
 	URL         string
 	Title       string
 	PublishTime time.Time
@@ -15,7 +15,7 @@ type ArticlePreview struct {
 }
 
 type Article struct {
-	ID              int
+	ID              uint64
 	URL             string
 	Title           string
 	PublishTime     time.Time
@@ -28,7 +28,7 @@ type Article struct {
 }
 
 type ArticleForm struct {
-	ID              int
+	ID              uint64
 	Title           string   `form:"title" validate:"required,max=255"`
 	Image           string   `form:"image" validate:"uri,max=255"`
 	URL             string   `form:"url" validate:"required,max=255"`
@@ -39,5 +39,5 @@ type ArticleForm struct {
 	IsActive        bool     `form:"is_active"`
 	PublishTime     string   `form:"publish_time" validate:"required"`
 	Tags            []string `form:"tag"`
-	ActiveTags      map[int]bool
+	ActiveTags      map[uint64]bool
 }

+ 2 - 2
internal/models/tag.go

@@ -1,13 +1,13 @@
 package models
 
 type Tag struct {
-	ID  int
+	ID  uint64
 	Tag string
 	URL string
 }
 
 type TagForm struct {
-	ID  int
+	ID  uint64
 	Tag string `form:"tag" validate:"required,max=255"`
 	URL string `form:"url" validate:"required,max=255"`
 }

+ 1 - 1
internal/models/user.go

@@ -5,7 +5,7 @@ import (
 )
 
 type User struct {
-	ID        int
+	ID        uint64
 	Username  string
 	Password  string
 	CreatedAt time.Time

+ 56 - 14
internal/repositories/article.go

@@ -2,8 +2,8 @@ package repositories
 
 import (
 	"context"
-	"database/sql"
 
+	"git.dmitriygnatenko.ru/dima/go-common/db"
 	sq "github.com/Masterminds/squirrel"
 
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
@@ -11,12 +11,25 @@ import (
 
 const articleTableName = "article"
 
+var articleTableColumns = []string{
+	"id",
+	"url",
+	"publish_time",
+	"title",
+	"image",
+	"text",
+	"preview_text",
+	"meta_keywords",
+	"meta_desc",
+	"is_active",
+}
+
 type ArticleRepository struct {
-	db *sql.DB
+	db db.DB
 }
 
-func InitArticleRepository(db *sql.DB) *ArticleRepository {
-	return &ArticleRepository{db: db}
+func InitArticleRepository(db *db.DB) *ArticleRepository {
+	return &ArticleRepository{db: *db}
 }
 
 func (a ArticleRepository) GetAllPreview(ctx context.Context) ([]models.ArticlePreview, error) {
@@ -60,8 +73,7 @@ func (a ArticleRepository) GetAllPreview(ctx context.Context) ([]models.ArticleP
 func (a ArticleRepository) GetAll(ctx context.Context) ([]models.Article, error) {
 	var res []models.Article
 
-	// nolint
-	query, args, err := sq.Select("id", "url", "publish_time", "title", "image", "text", "preview_text", "meta_keywords", "meta_desc", "is_active").
+	query, args, err := sq.Select(articleTableColumns...).
 		From(articleTableName).
 		OrderBy("publish_time DESC").
 		ToSql()
@@ -75,7 +87,18 @@ func (a ArticleRepository) GetAll(ctx context.Context) ([]models.Article, error)
 	for rows.Next() {
 		row := models.Article{}
 
-		err = rows.Scan(&row.ID, &row.URL, &row.PublishTime, &row.Title, &row.Image, &row.Text, &row.PreviewText, &row.MetaKeywords, &row.MetaDescription, &row.IsActive)
+		err = rows.Scan(
+			&row.ID,
+			&row.URL,
+			&row.PublishTime,
+			&row.Title,
+			&row.Image,
+			&row.Text,
+			&row.PreviewText,
+			&row.MetaKeywords,
+			&row.MetaDescription,
+			&row.IsActive,
+		)
 		if err != nil {
 			return nil, err
 		}
@@ -90,7 +113,7 @@ func (a ArticleRepository) GetAll(ctx context.Context) ([]models.Article, error)
 	return res, nil
 }
 
-func (a ArticleRepository) GetPreviewByTagID(ctx context.Context, tagID int) ([]models.ArticlePreview, error) {
+func (a ArticleRepository) GetPreviewByTagID(ctx context.Context, tagID uint64) ([]models.ArticlePreview, error) {
 	var res []models.ArticlePreview
 
 	query := "SELECT a.id, a.url, a.publish_time, a.title, a.preview_text, a.image " +
@@ -125,7 +148,7 @@ func (a ArticleRepository) GetPreviewByTagID(ctx context.Context, tagID int) ([]
 func (a ArticleRepository) GetByURL(ctx context.Context, url string) (*models.Article, error) {
 	var res models.Article
 
-	query, args, err := sq.Select("id", "url", "publish_time", "title", "image", "text", "preview_text", "meta_keywords", "meta_desc", "is_active").
+	query, args, err := sq.Select(articleTableColumns...).
 		From(articleTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"url": url}).
@@ -146,10 +169,10 @@ func (a ArticleRepository) GetByURL(ctx context.Context, url string) (*models.Ar
 	return &res, nil
 }
 
-func (a ArticleRepository) GetByID(ctx context.Context, id int) (*models.Article, error) {
+func (a ArticleRepository) GetByID(ctx context.Context, id uint64) (*models.Article, error) {
 	var res models.Article
 
-	query, args, err := sq.Select("id", "url", "publish_time", "title", "image", "text", "preview_text", "meta_keywords", "meta_desc", "is_active").
+	query, args, err := sq.Select(articleTableColumns...).
 		From(articleTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"id": id}).
@@ -173,8 +196,27 @@ func (a ArticleRepository) GetByID(ctx context.Context, id int) (*models.Article
 func (a ArticleRepository) Add(ctx context.Context, m models.Article) (int, error) {
 	query, args, err := sq.Insert(articleTableName).
 		PlaceholderFormat(sq.Dollar).
-		Columns("url", "publish_time", "title", "image", "text", "preview_text", "meta_keywords", "meta_desc", "is_active").
-		Values(m.URL, m.PublishTime, m.Title, m.Image, m.Text, m.PreviewText, m.MetaKeywords, m.MetaDescription, m.IsActive).
+		Columns(
+			"url",
+			"publish_time",
+			"title",
+			"image",
+			"text",
+			"preview_text",
+			"meta_keywords",
+			"meta_desc",
+			"is_active",
+		).Values(
+		m.URL,
+		m.PublishTime,
+		m.Title,
+		m.Image,
+		m.Text,
+		m.PreviewText,
+		m.MetaKeywords,
+		m.MetaDescription,
+		m.IsActive,
+	).
 		Suffix("RETURNING id").
 		ToSql()
 
@@ -215,7 +257,7 @@ func (a ArticleRepository) Update(ctx context.Context, req models.Article) error
 	return err
 }
 
-func (a ArticleRepository) Delete(ctx context.Context, id int) error {
+func (a ArticleRepository) Delete(ctx context.Context, id uint64) error {
 	query, args, err := sq.Delete(articleTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"id": id}).

+ 6 - 6
internal/repositories/article_tag.go

@@ -2,22 +2,22 @@ package repositories
 
 import (
 	"context"
-	"database/sql"
 
+	"git.dmitriygnatenko.ru/dima/go-common/db"
 	sq "github.com/Masterminds/squirrel"
 )
 
 const articleTagTableName = "article_tag"
 
 type ArticleTagRepository struct {
-	db *sql.DB
+	db *db.DB
 }
 
-func InitArticleTagRepository(db *sql.DB) *ArticleTagRepository {
+func InitArticleTagRepository(db *db.DB) *ArticleTagRepository {
 	return &ArticleTagRepository{db: db}
 }
 
-func (a ArticleTagRepository) Add(ctx context.Context, articleID int, tagIDs []int) error {
+func (a ArticleTagRepository) Add(ctx context.Context, articleID uint64, tagIDs []uint64) error {
 	if len(tagIDs) == 0 {
 		return nil
 	}
@@ -40,7 +40,7 @@ func (a ArticleTagRepository) Add(ctx context.Context, articleID int, tagIDs []i
 	return err
 }
 
-func (a ArticleTagRepository) Delete(ctx context.Context, articleID int, tagIDs []int) error {
+func (a ArticleTagRepository) Delete(ctx context.Context, articleID uint64, tagIDs []uint64) error {
 	if len(tagIDs) == 0 {
 		return nil
 	}
@@ -60,7 +60,7 @@ func (a ArticleTagRepository) Delete(ctx context.Context, articleID int, tagIDs
 	return err
 }
 
-func (a ArticleTagRepository) DeleteByArticleID(ctx context.Context, articleID int) error {
+func (a ArticleTagRepository) DeleteByArticleID(ctx context.Context, articleID uint64) error {
 	query, args, err := sq.Delete(articleTagTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"article_id": articleID}).

+ 7 - 7
internal/repositories/tag.go

@@ -2,8 +2,8 @@ package repositories
 
 import (
 	"context"
-	"database/sql"
 
+	"git.dmitriygnatenko.ru/dima/go-common/db"
 	sq "github.com/Masterminds/squirrel"
 
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
@@ -12,10 +12,10 @@ import (
 const tagTableName = "tag"
 
 type TagRepository struct {
-	db *sql.DB
+	db *db.DB
 }
 
-func InitTagRepository(db *sql.DB) *TagRepository {
+func InitTagRepository(db *db.DB) *TagRepository {
 	return &TagRepository{db: db}
 }
 
@@ -76,7 +76,7 @@ func (t TagRepository) GetByURL(ctx context.Context, url string) (*models.Tag, e
 	return &res, nil
 }
 
-func (t TagRepository) GetByID(ctx context.Context, id int) (*models.Tag, error) {
+func (t TagRepository) GetByID(ctx context.Context, id uint64) (*models.Tag, error) {
 	query, args, err := sq.Select("id", "url", "tag").
 		From(tagTableName).
 		PlaceholderFormat(sq.Dollar).
@@ -135,7 +135,7 @@ func (t TagRepository) GetAll(ctx context.Context) ([]models.Tag, error) {
 	return res, nil
 }
 
-func (t TagRepository) GetByArticleID(ctx context.Context, id int) ([]models.Tag, error) {
+func (t TagRepository) GetByArticleID(ctx context.Context, id uint64) ([]models.Tag, error) {
 	var res []models.Tag
 
 	query := "SELECT t.id, t.url, t.tag " +
@@ -166,7 +166,7 @@ func (t TagRepository) GetByArticleID(ctx context.Context, id int) ([]models.Tag
 	return res, nil
 }
 
-func (t TagRepository) IsUsed(ctx context.Context, id int) (bool, error) {
+func (t TagRepository) IsUsed(ctx context.Context, id uint64) (bool, error) {
 	query, args, err := sq.Select("COUNT(tag_id)").
 		From(articleTagTableName).
 		PlaceholderFormat(sq.Dollar).
@@ -219,7 +219,7 @@ func (t TagRepository) Update(ctx context.Context, req models.Tag) error {
 	return err
 }
 
-func (t TagRepository) Delete(ctx context.Context, id int) error {
+func (t TagRepository) Delete(ctx context.Context, id uint64) error {
 	query, args, err := sq.Delete(tagTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"id": id}).

+ 4 - 4
internal/repositories/user.go

@@ -2,8 +2,8 @@ package repositories
 
 import (
 	"context"
-	"database/sql"
 
+	"git.dmitriygnatenko.ru/dima/go-common/db"
 	sq "github.com/Masterminds/squirrel"
 
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
@@ -14,10 +14,10 @@ const (
 )
 
 type UserRepository struct {
-	db *sql.DB
+	db *db.DB
 }
 
-func InitUserRepository(db *sql.DB) *UserRepository {
+func InitUserRepository(db *db.DB) *UserRepository {
 	return &UserRepository{db: db}
 }
 
@@ -63,7 +63,7 @@ func (u UserRepository) Add(ctx context.Context, username string, password strin
 	return id, nil
 }
 
-func (u UserRepository) UpdatePassword(ctx context.Context, id int, newPassword string) error {
+func (u UserRepository) UpdatePassword(ctx context.Context, id uint64, newPassword string) error {
 	query, args, err := sq.Update(userTableName).
 		PlaceholderFormat(sq.Dollar).
 		Set("password", newPassword).

+ 74 - 21
internal/service_provider/sp.go

@@ -1,18 +1,21 @@
 package sp
 
 import (
+	"log/slog"
+
+	cacheService "git.dmitriygnatenko.ru/dima/go-common/cache/ttl_memory_cache"
+	dbService "git.dmitriygnatenko.ru/dima/go-common/db"
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
+	smtpService "git.dmitriygnatenko.ru/dima/go-common/smtp"
+
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/repositories"
 	authService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/auth"
-	cacheService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/cache"
-	dbService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/db"
 	envService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/env"
-	mailService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/mailer"
 )
 
 type ServiceProvider struct {
 	env                  *envService.Service
-	cache                *cacheService.Service
-	mailer               *mailService.Service
+	cache                *cacheService.Cache
 	auth                 *authService.Service
 	articleRepository    *repositories.ArticleRepository
 	tagRepository        *repositories.TagRepository
@@ -30,17 +33,14 @@ func Init() (*ServiceProvider, error) {
 	}
 	sp.env = env
 
-	cache, err := cacheService.Init()
-	if err != nil {
-		return nil, err
-	}
-	sp.cache = cache
+	cache := cacheService.NewCache(
+		cacheService.NewConfig(
+			cacheService.WithExpiration(env.CacheDefaultDuration()),
+			cacheService.WithCleanupInterval(env.CacheCleanupInterval()),
+		),
+	)
 
-	mailer, err := mailService.Init(sp.env)
-	if err != nil {
-		return nil, err
-	}
-	sp.mailer = mailer
+	sp.cache = cache
 
 	auth, err := authService.Init(sp.env)
 	if err != nil {
@@ -48,17 +48,74 @@ func Init() (*ServiceProvider, error) {
 	}
 	sp.auth = auth
 
-	db, err := dbService.Init(env)
+	db, err := dbService.NewDB(
+		dbService.NewConfig(
+			dbService.WithDriver(env.DbDriver()),
+			dbService.WithHost(env.DbHost()),
+			dbService.WithPort(uint16(env.DbPort())),
+			dbService.WithUsername(env.DbUser()),
+			dbService.WithPassword(env.DbPassword()),
+			dbService.WithDatabase(env.DbName()),
+			dbService.WithMaxIdleConns(env.DbMaxIdleConns()),
+			dbService.WithMaxOpenConns(env.DbMaxOpenConns()),
+			dbService.WithMaxIdleConnLifetime(env.DbMaxOpenConnLifetime()),
+			dbService.WithMaxOpenConnLifetime(env.DbMaxOpenConnLifetime()),
+			dbService.WithSSLMode(env.DbSSLMode()),
+		),
+	)
+
 	if err != nil {
 		return nil, err
 	}
 
+	smtp, err := smtpService.NewSMTP(
+		smtpService.NewConfig(
+			smtpService.WithPassword(env.SmtpPassword()),
+			smtpService.WithUsername(env.SmtpUser()),
+			smtpService.WithHost(env.SmtpHost()),
+			smtpService.WithPort(uint16(env.SmtpPort())),
+		),
+	)
+
 	// Init repositories
 	sp.articleRepository = repositories.InitArticleRepository(db)
 	sp.tagRepository = repositories.InitTagRepository(db)
 	sp.articleTagRepository = repositories.InitArticleTagRepository(db)
 	sp.userRepository = repositories.InitUserRepository(db)
 
+	// Init logger
+	loggerOpts := logger.ConfigOptions{
+		logger.WithSMTPClient(smtp),
+		logger.WithEmailLogEnabled(env.LoggerEmailEnabled()),
+		logger.WithEmailSubject(env.LoggerEmailSubject()),
+		logger.WithEmailRecipient(env.LoggerEmail()),
+		logger.WithEmailLogAddSource(env.LoggerEmailAddSource()),
+		logger.WithStdoutLogEnabled(env.LoggerStdoutEnabled()),
+		logger.WithStdoutLogAddSource(env.LoggerStdoutAddSource()),
+	}
+
+	if len(env.LoggerEmailLevel()) > 0 {
+		var level slog.Level
+		if err = level.UnmarshalText([]byte(env.LoggerEmailLevel())); err != nil {
+			return nil, err
+		}
+
+		loggerOpts.Add(logger.WithEmailLogLevel(level))
+	}
+
+	if len(env.LoggerStdoutLevel()) > 0 {
+		var level slog.Level
+		if err = level.UnmarshalText([]byte(env.LoggerStdoutLevel())); err != nil {
+			return nil, err
+		}
+
+		loggerOpts.Add(logger.WithStdoutLogLevel(level))
+	}
+
+	if err = logger.Init(logger.NewConfig(loggerOpts...)); err != nil {
+		return nil, err
+	}
+
 	return sp, nil
 }
 
@@ -66,14 +123,10 @@ func (sp *ServiceProvider) EnvService() *envService.Service {
 	return sp.env
 }
 
-func (sp *ServiceProvider) CacheService() *cacheService.Service {
+func (sp *ServiceProvider) CacheService() *cacheService.Cache {
 	return sp.cache
 }
 
-func (sp *ServiceProvider) MailerService() *mailService.Service {
-	return sp.mailer
-}
-
 func (sp *ServiceProvider) AuthService() *authService.Service {
 	return sp.auth
 }

+ 4 - 4
internal/services/auth/auth.go

@@ -18,8 +18,8 @@ const (
 )
 
 type Env interface {
-	JWTSecretKey() string
-	JWTLifetime() int
+	JwtSecretKey() string
+	JwtLifeTime() time.Duration
 }
 
 type Service struct {
@@ -53,10 +53,10 @@ func (a Service) GetClaims(fctx *fiber.Ctx) jwt.MapClaims {
 func (a Service) GenerateToken(user models.User) (string, error) {
 	claims := jwt.MapClaims{
 		ClaimNameKey: user.Username,
-		claimExpKey:  time.Now().Add(time.Duration(a.env.JWTLifetime()) * time.Second).Unix(),
+		claimExpKey:  time.Now().Add(a.env.JwtLifeTime() * time.Second).Unix(),
 	}
 
 	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
 
-	return token.SignedString([]byte(a.env.JWTSecretKey()))
+	return token.SignedString([]byte(a.env.JwtSecretKey()))
 }

+ 0 - 42
internal/services/cache/cache.go

@@ -1,42 +0,0 @@
-package cache
-
-import (
-	"sync"
-)
-
-type Service struct {
-	data map[string]interface{}
-	sync.RWMutex
-}
-
-func Init() (*Service, error) {
-	return &Service{
-		data: make(map[string]interface{}),
-	}, nil
-}
-
-func (c *Service) Get(key string) (interface{}, bool) {
-	c.RLock()
-	defer c.RUnlock()
-
-	res, found := c.data[key]
-	if !found {
-		return nil, false
-	}
-
-	return res, true
-}
-
-func (c *Service) Set(key string, value interface{}) {
-	c.Lock()
-	defer c.Unlock()
-
-	c.data[key] = value
-}
-
-func (c *Service) FlushAll() {
-	c.Lock()
-	defer c.Unlock()
-
-	c.data = make(map[string]interface{})
-}

+ 0 - 55
internal/services/db/db.go

@@ -1,55 +0,0 @@
-package db
-
-import (
-	"database/sql"
-	"time"
-)
-
-type Env interface {
-	AppPort() string
-	DBHost() string
-	DBPort() string
-	DBName() string
-	DBUser() string
-	DBPassword() string
-	DBMaxOpenConns() int
-	DBMaxIdleConns() int
-	DBMaxConnLifetime() int
-	DBMaxIdleConnLifetime() int
-}
-
-func Init(env Env) (*sql.DB, error) {
-	dataSource := "user=" + env.DBUser() +
-		" password=" + env.DBPassword() +
-		" dbname=" + env.DBName() +
-		" host=" + env.DBHost() +
-		" port=" + env.DBPort() +
-		" sslmode=disable"
-
-	db, err := sql.Open("postgres", dataSource)
-	if err != nil {
-		return nil, err
-	}
-
-	if env.DBMaxOpenConns() > 0 {
-		db.SetMaxOpenConns(env.DBMaxOpenConns())
-	}
-
-	if env.DBMaxIdleConns() > 0 {
-		db.SetMaxIdleConns(env.DBMaxIdleConns())
-	}
-
-	if env.DBMaxConnLifetime() > 0 {
-		db.SetConnMaxLifetime(time.Second * time.Duration(env.DBMaxConnLifetime()))
-	}
-
-	if env.DBMaxIdleConnLifetime() > 0 {
-		db.SetConnMaxIdleTime(time.Second * time.Duration(env.DBMaxIdleConnLifetime()))
-	}
-
-	if err = db.Ping(); err != nil {
-		return nil, err
-	}
-
-	return db, nil
-}

+ 168 - 97
internal/services/env/env.go

@@ -2,6 +2,7 @@ package env
 
 import (
 	"flag"
+	"time"
 
 	"github.com/spf13/viper"
 )
@@ -9,30 +10,40 @@ import (
 const defaultConfigPath = "../../.env"
 
 type Service struct {
-	appPort               string
+	appPort               uint64
+	appCORSAllowOrigins   string
+	appCORSAllowMethods   string
+	dbDriver              string
 	dbHost                string
-	dbPort                string
+	dbPort                uint64
 	dbName                string
 	dbUser                string
 	dbPassword            string
-	dbMaxOpenConns        int
-	dbMaxIdleConns        int
-	dbMaxConnLifetime     int
-	dbMaxIdleConnLifetime int
-	corsAllowOrigins      string
-	corsAllowMethods      string
+	dbSSLMode             string
+	dbMaxOpenConns        uint16
+	dbMaxIdleConns        uint16
+	dbMaxOpenConnLifetime time.Duration
+	dbMaxIdleConnLifetime time.Duration
+	cacheDefaultDuration  time.Duration
+	cacheCleanupInterval  time.Duration
+	smtpHost              string
+	smtpPort              uint64
+	smtpUser              string
+	smtpPassword          string
 	jwtSecretKey          string
-	jwtLifeTime           int
+	jwtLifeTime           time.Duration
 	jwtCookie             string
 	basicAuthUser         string
 	basicAuthPassword     string
-	smtpHost              string
-	smtpPort              string
-	smtpUser              string
-	smtpPassword          string
-	errorsEmail           string
-	gaKey                 string
-	staticVersion         int
+	staticVersion         uint64
+	loggerStdoutEnabled   bool
+	loggerStdoutLevel     string
+	loggerStdoutAddSource bool
+	loggerEmailEnabled    bool
+	loggerEmailLevel      string
+	loggerEmailAddSource  bool
+	loggerEmailSubject    string
+	loggerEmail           string
 }
 
 func Init() (*Service, error) {
@@ -53,30 +64,40 @@ func Init() (*Service, error) {
 	}
 
 	s := struct {
-		AppPort               string `mapstructure:"APP_PORT"`
-		DBHost                string `mapstructure:"DB_HOST"`
-		DBPort                string `mapstructure:"DB_PORT"`
-		DBName                string `mapstructure:"DB_NAME"`
-		DBUser                string `mapstructure:"DB_USER"`
-		DBPassword            string `mapstructure:"DB_PASSWORD"`
-		DBMaxOpenConns        int    `mapstructure:"DB_MAX_OPEN_CONNS"`
-		DBMaxIdleConns        int    `mapstructure:"DB_MAX_IDLE_CONNS"`
-		DBMaxConnLifetime     int    `mapstructure:"DB_MAX_CONN_LIFETIME"`
-		DBMaxIdleConnLifetime int    `mapstructure:"DB_MAX_IDLE_CONN_LIFETIME"`
-		CORSAllowOrigins      string `mapstructure:"CORS_ALLOW_ORIGING"`
-		CORSAllowMethods      string `mapstructure:"CORS_ALLOW_METHODS"`
-		JWTSecretKey          string `mapstructure:"JWT_SECRET_KEY"`
-		JWTLifeTime           int    `mapstructure:"JWT_LIFETIME"`
-		JWTCookie             string `mapstructure:"JWT_COOKIE"`
-		BasicAuthUser         string `mapstructure:"BASIC_AUTH_USER"`
-		BasicAuthPassword     string `mapstructure:"BASIC_AUTH_PASSWORD"`
-		SMTPHost              string `mapstructure:"SMTP_HOST"`
-		SMTPPort              string `mapstructure:"SMTP_PORT"`
-		SMTPUser              string `mapstructure:"SMTP_USER"`
-		SMTPPassword          string `mapstructure:"SMTP_PASSWORD"`
-		ErrorsEmail           string `mapstructure:"ERRORS_EMAIL"`
-		GAKey                 string `mapstructure:"GA_KEY"`
-		StaticVersion         int    `mapstructure:"STATIC_VERSION"`
+		AppPort               uint64        `mapstructure:"APP_PORT"`
+		AppCORSAllowOrigins   string        `mapstructure:"APP_CORS_ALLOW_ORIGIN"`
+		AppCORSAllowMethods   string        `mapstructure:"APP_CORS_ALLOW_METHODS"`
+		DBDriver              string        `mapstructure:"DB_DRIVER"`
+		DBHost                string        `mapstructure:"DB_HOST"`
+		DBPort                uint64        `mapstructure:"DB_PORT"`
+		DBName                string        `mapstructure:"DB_NAME"`
+		DBUser                string        `mapstructure:"DB_USER"`
+		DBPassword            string        `mapstructure:"DB_PASSWORD"`
+		DBSSLMode             string        `mapstructure:"DB_SSL_MODE"`
+		DBMaxOpenConns        uint16        `mapstructure:"DB_MAX_OPEN_CONNS"`
+		DBMaxIdleConns        uint16        `mapstructure:"DB_MAX_IDLE_CONNS"`
+		DBMaxOpenConnLifetime time.Duration `mapstructure:"DB_MAX_OPEN_CONN_LIFETIME"`
+		DBMaxIdleConnLifetime time.Duration `mapstructure:"DB_MAX_IDLE_CONN_LIFETIME"`
+		CacheDefaultDuration  time.Duration `mapstructure:"CACHE_DEFAULT_EXPIRATION"`
+		CacheCleanupInterval  time.Duration `mapstructure:"CACHE_CLEANUP_INTERVAL"`
+		SMTPHost              string        `mapstructure:"SMTP_HOST"`
+		SMTPPort              uint64        `mapstructure:"SMTP_PORT"`
+		SMTPUser              string        `mapstructure:"SMTP_USER"`
+		SMTPPassword          string        `mapstructure:"SMTP_PASSWORD"`
+		JWTSecretKey          string        `mapstructure:"JWT_SECRET_KEY"`
+		JWTLifeTime           time.Duration `mapstructure:"JWT_LIFETIME"`
+		JWTCookie             string        `mapstructure:"JWT_COOKIE"`
+		BasicAuthUser         string        `mapstructure:"BASIC_AUTH_USER"`
+		BasicAuthPassword     string        `mapstructure:"BASIC_AUTH_PASSWORD"`
+		StaticVersion         uint64        `mapstructure:"STATIC_VERSION"`
+		LoggerStdoutEnabled   bool          `mapstructure:"LOGGER_STDOUT_ENABLED"`
+		LoggerStdoutLevel     string        `mapstructure:"LOGGER_STDOUT_LEVEL"`
+		LoggerStdoutAddSource bool          `mapstructure:"LOGGER_STDOUT_ADD_SOURCE"`
+		LoggerEmailEnabled    bool          `mapstructure:"LOGGER_EMAIL_ENABLED"`
+		LoggerEmailLevel      string        `mapstructure:"LOGGER_EMAIL_LEVEL"`
+		LoggerEmailAddSource  bool          `mapstructure:"LOGGER_EMAIL_ADD_SOURCE"`
+		LoggerEmailSubject    string        `mapstructure:"LOGGER_EMAIL_SUBJECT"`
+		LoggerEmail           string        `mapstructure:"LOGGER_EMAIL"`
 	}{}
 
 	if err := viper.Unmarshal(&s); err != nil {
@@ -85,124 +106,174 @@ func Init() (*Service, error) {
 
 	return &Service{
 		appPort:               s.AppPort,
+		appCORSAllowOrigins:   s.AppCORSAllowOrigins,
+		appCORSAllowMethods:   s.AppCORSAllowMethods,
+		dbDriver:              s.DBDriver,
 		dbHost:                s.DBHost,
 		dbPort:                s.DBPort,
 		dbName:                s.DBName,
 		dbUser:                s.DBUser,
 		dbPassword:            s.DBPassword,
+		dbSSLMode:             s.LoggerEmail,
 		dbMaxOpenConns:        s.DBMaxOpenConns,
 		dbMaxIdleConns:        s.DBMaxIdleConns,
-		dbMaxConnLifetime:     s.DBMaxConnLifetime,
+		dbMaxOpenConnLifetime: s.DBMaxOpenConnLifetime,
 		dbMaxIdleConnLifetime: s.DBMaxIdleConnLifetime,
-		corsAllowOrigins:      s.CORSAllowOrigins,
-		corsAllowMethods:      s.CORSAllowMethods,
+		cacheDefaultDuration:  s.CacheDefaultDuration,
+		cacheCleanupInterval:  s.CacheCleanupInterval,
+		smtpHost:              s.SMTPHost,
+		smtpPort:              s.SMTPPort,
+		smtpUser:              s.SMTPUser,
+		smtpPassword:          s.SMTPPassword,
 		jwtSecretKey:          s.JWTSecretKey,
 		jwtLifeTime:           s.JWTLifeTime,
 		jwtCookie:             s.JWTCookie,
 		basicAuthUser:         s.BasicAuthUser,
 		basicAuthPassword:     s.BasicAuthPassword,
-		smtpHost:              s.SMTPHost,
-		smtpPort:              s.SMTPPort,
-		smtpUser:              s.SMTPUser,
-		smtpPassword:          s.SMTPPassword,
-		errorsEmail:           s.ErrorsEmail,
-		gaKey:                 s.GAKey,
 		staticVersion:         s.StaticVersion,
+		loggerStdoutEnabled:   s.LoggerStdoutEnabled,
+		loggerStdoutLevel:     s.LoggerStdoutLevel,
+		loggerStdoutAddSource: s.LoggerStdoutAddSource,
+		loggerEmailEnabled:    s.LoggerEmailEnabled,
+		loggerEmailLevel:      s.LoggerEmailLevel,
+		loggerEmailAddSource:  s.LoggerEmailAddSource,
+		loggerEmailSubject:    s.LoggerEmailSubject,
+		loggerEmail:           s.LoggerEmail,
 	}, nil
 }
 
-func (e *Service) AppPort() string {
-	return e.appPort
+func (s Service) AppPort() uint64 {
+	return s.appPort
+}
+
+func (s Service) AppCORSAllowOrigins() string {
+	return s.appCORSAllowOrigins
+}
+
+func (s Service) AppCORSAllowMethods() string {
+	return s.appCORSAllowMethods
+}
+
+func (s Service) DbDriver() string {
+	return s.dbDriver
+}
+
+func (s Service) DbHost() string {
+	return s.dbHost
+}
+
+func (s Service) DbPort() uint64 {
+	return s.dbPort
+}
+
+func (s Service) DbName() string {
+	return s.dbName
+}
+
+func (s Service) DbUser() string {
+	return s.dbUser
+}
+
+func (s Service) DbPassword() string {
+	return s.dbPassword
+}
+
+func (s Service) DbSSLMode() string {
+	return s.dbSSLMode
+}
+
+func (s Service) DbMaxOpenConns() uint16 {
+	return s.dbMaxOpenConns
 }
 
-func (e *Service) DBHost() string {
-	return e.dbHost
+func (s Service) DbMaxIdleConns() uint16 {
+	return s.dbMaxIdleConns
 }
 
-func (e *Service) DBPort() string {
-	return e.dbPort
+func (s Service) DbMaxOpenConnLifetime() time.Duration {
+	return s.dbMaxOpenConnLifetime
 }
 
-func (e *Service) DBName() string {
-	return e.dbName
+func (s Service) DbMaxIdleConnLifetime() time.Duration {
+	return s.dbMaxIdleConnLifetime
 }
 
-func (e *Service) DBUser() string {
-	return e.dbUser
+func (s Service) CacheDefaultDuration() time.Duration {
+	return s.cacheDefaultDuration
 }
 
-func (e *Service) DBPassword() string {
-	return e.dbPassword
+func (s Service) CacheCleanupInterval() time.Duration {
+	return s.cacheCleanupInterval
 }
 
-func (e *Service) CORSAllowOrigins() string {
-	return e.corsAllowOrigins
+func (s Service) SmtpHost() string {
+	return s.smtpHost
 }
 
-func (e *Service) CORSAllowMethods() string {
-	return e.corsAllowMethods
+func (s Service) SmtpPort() uint64 {
+	return s.smtpPort
 }
 
-func (e *Service) DBMaxOpenConns() int {
-	return e.dbMaxOpenConns
+func (s Service) SmtpUser() string {
+	return s.smtpUser
 }
 
-func (e *Service) DBMaxIdleConns() int {
-	return e.dbMaxIdleConns
+func (s Service) SmtpPassword() string {
+	return s.smtpPassword
 }
 
-func (e *Service) DBMaxConnLifetime() int {
-	return e.dbMaxConnLifetime
+func (s Service) JwtSecretKey() string {
+	return s.jwtSecretKey
 }
 
-func (e *Service) DBMaxIdleConnLifetime() int {
-	return e.dbMaxIdleConnLifetime
+func (s Service) JwtLifeTime() time.Duration {
+	return s.jwtLifeTime
 }
 
-func (e *Service) SMTPHost() string {
-	return e.smtpHost
+func (s Service) JwtCookie() string {
+	return s.jwtCookie
 }
 
-func (e *Service) SMTPPort() string {
-	return e.smtpPort
+func (s Service) BasicAuthUser() string {
+	return s.basicAuthUser
 }
 
-func (e *Service) SMTPUser() string {
-	return e.smtpUser
+func (s Service) BasicAuthPassword() string {
+	return s.basicAuthPassword
 }
 
-func (e *Service) SMTPPassword() string {
-	return e.smtpPassword
+func (s Service) StaticVersion() uint64 {
+	return s.staticVersion
 }
 
-func (e *Service) JWTSecretKey() string {
-	return e.jwtSecretKey
+func (s Service) LoggerStdoutEnabled() bool {
+	return s.loggerStdoutEnabled
 }
 
-func (e *Service) JWTCookie() string {
-	return e.jwtCookie
+func (s Service) LoggerStdoutLevel() string {
+	return s.loggerStdoutLevel
 }
 
-func (e *Service) JWTLifetime() int {
-	return e.jwtLifeTime
+func (s Service) LoggerStdoutAddSource() bool {
+	return s.loggerStdoutAddSource
 }
 
-func (e *Service) ErrorsEmail() string {
-	return e.errorsEmail
+func (s Service) LoggerEmailEnabled() bool {
+	return s.loggerEmailEnabled
 }
 
-func (e *Service) BasicAuthUser() string {
-	return e.basicAuthUser
+func (s Service) LoggerEmailLevel() string {
+	return s.loggerEmailLevel
 }
 
-func (e *Service) BasicAuthPassword() string {
-	return e.basicAuthPassword
+func (s Service) LoggerEmailAddSource() bool {
+	return s.loggerEmailAddSource
 }
 
-func (e *Service) StaticVersion() int {
-	return e.staticVersion
+func (s Service) LoggerEmailSubject() string {
+	return s.loggerEmailSubject
 }
 
-func (e *Service) GAKey() string {
-	return e.gaKey
+func (s Service) LoggerEmail() string {
+	return s.loggerEmail
 }

+ 30 - 22
internal/services/handler/admin/article.go

@@ -3,7 +3,9 @@ package admin
 import (
 	"context"
 	"strconv"
+	"time"
 
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
 	"github.com/go-playground/validator/v10"
 	"github.com/gofiber/fiber/v2"
 	"github.com/golang-jwt/jwt/v4"
@@ -11,37 +13,38 @@ import (
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/mapper"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/handler"
 )
 
 type (
 	CacheService interface {
-		FlushAll()
+		Delete(key string)
 	}
 
 	ArticleRepository interface {
 		GetAll(ctx context.Context) ([]models.Article, error)
 		GetByURL(ctx context.Context, url string) (*models.Article, error)
-		GetByID(ctx context.Context, ID int) (*models.Article, error)
-		Add(ctx context.Context, m models.Article) (int, error)
+		GetByID(ctx context.Context, ID uint64) (*models.Article, error)
+		Add(ctx context.Context, m models.Article) (uint64, error)
 		Update(ctx context.Context, m models.Article) error
-		Delete(ctx context.Context, ID int) error
+		Delete(ctx context.Context, ID uint64) error
 	}
 
 	TagRepository interface {
 		GetAll(ctx context.Context) ([]models.Tag, error)
-		IsUsed(ctx context.Context, ID int) (bool, error)
-		GetByArticleID(ctx context.Context, ID int) ([]models.Tag, error)
+		IsUsed(ctx context.Context, ID uint64) (bool, error)
+		GetByArticleID(ctx context.Context, ID uint64) ([]models.Tag, error)
 		GetByURL(ctx context.Context, tag string) (*models.Tag, error)
-		GetByID(ctx context.Context, ID int) (*models.Tag, error)
+		GetByID(ctx context.Context, ID uint64) (*models.Tag, error)
 		Add(ctx context.Context, m models.Tag) error
 		Update(ctx context.Context, m models.Tag) error
-		Delete(ctx context.Context, ID int) error
+		Delete(ctx context.Context, ID uint64) error
 	}
 
 	ArticleTagRepository interface {
-		Add(ctx context.Context, articleID int, tagIDs []int) error
-		Delete(ctx context.Context, articleID int, tagIDs []int) error
-		DeleteByArticleID(ctx context.Context, articleID int) error
+		Add(ctx context.Context, articleID uint64, tagIDs []uint64) error
+		Delete(ctx context.Context, articleID uint64, tagIDs []uint64) error
+		DeleteByArticleID(ctx context.Context, articleID uint64) error
 	}
 
 	AuthService interface {
@@ -52,14 +55,14 @@ type (
 	}
 
 	EnvService interface {
-		JWTCookie() string
-		JWTLifetime() int
+		JwtCookie() string
+		JwtLifeTime() time.Duration
 	}
 
 	UserRepository interface {
 		Get(ctx context.Context, username string) (*models.User, error)
 		Add(ctx context.Context, username string, password string) (int, error)
-		UpdatePassword(ctx context.Context, id int, newPassword string) error
+		UpdatePassword(ctx context.Context, id uint64, newPassword string) error
 	}
 )
 
@@ -69,6 +72,7 @@ func ArticleHandler(articleRepository ArticleRepository) fiber.Handler {
 	return func(ctx *fiber.Ctx) error {
 		articles, err := articleRepository.GetAll(ctx.Context())
 		if err != nil {
+			logger.Error(ctx.Context(), err.Error())
 			return err
 		}
 
@@ -83,7 +87,6 @@ func AddArticleHandler(
 	articleRepository ArticleRepository,
 	tagRepository TagRepository,
 	articleTagRepository ArticleTagRepository,
-	cacheService CacheService,
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
@@ -92,15 +95,17 @@ func AddArticleHandler(
 
 		trans, err := helpers.GetDefaultTranslator(validate)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
 		form := models.ArticleForm{
-			ActiveTags: make(map[int]bool),
+			ActiveTags: make(map[uint64]bool),
 		}
 
 		tags, err := tagRepository.GetAll(ctx)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
@@ -108,6 +113,7 @@ func AddArticleHandler(
 
 		if fctx.Method() == fiber.MethodPost {
 			if err = fctx.BodyParser(&form); err != nil {
+				logger.Error(ctx, err.Error())
 				return err
 			}
 
@@ -119,10 +125,11 @@ func AddArticleHandler(
 				validateErrors["ArticleForm.URL"] = errArticleExists
 			}
 
-			tagIDs := make([]int, 0, len(form.Tags))
+			tagIDs := make([]uint64, 0, len(form.Tags))
 			for i := range form.Tags {
-				tagID, tagErr := strconv.Atoi(form.Tags[i])
+				tagID, tagErr := strconv.ParseUint(form.Tags[i], 10, 64)
 				if tagErr != nil {
+					logger.Error(ctx, tagErr.Error())
 					return tagErr
 				}
 
@@ -136,22 +143,23 @@ func AddArticleHandler(
 			if len(validateErrors) == 0 {
 				articleModel, err := mapper.ToArticle(form)
 				if err != nil {
+					logger.Error(ctx, err.Error())
 					return err
 				}
 
 				articleID, articleErr := articleRepository.Add(ctx, *articleModel)
 				if articleErr != nil {
+					logger.Error(ctx, articleErr.Error())
 					return articleErr
 				}
 
 				if len(form.Tags) > 0 {
 					if err = articleTagRepository.Add(ctx, articleID, tagIDs); err != nil {
+						logger.Error(ctx, err.Error())
 						return err
 					}
 				}
 
-				cacheService.FlushAll()
-
 				return fctx.Redirect("/admin")
 			}
 		}
@@ -284,7 +292,7 @@ func EditArticleHandler(
 					}
 				}
 
-				cacheService.FlushAll()
+				cacheService.Delete(handler.ArticleCacheKey + article.URL)
 
 				if fctx.FormValue("action", "save") == "save" {
 					return fctx.Redirect("/admin")
@@ -331,7 +339,7 @@ func DeleteArticleHandler(
 				return err
 			}
 
-			cacheService.FlushAll()
+			cacheService.Delete(handler.ArticleCacheKey + article.URL)
 
 			return fctx.Redirect("/admin")
 		}

+ 3 - 3
internal/services/handler/admin/auth.go

@@ -49,9 +49,9 @@ func LoginHandler(
 						}
 
 						cookie := new(fiber.Cookie)
-						cookie.Name = envService.JWTCookie()
+						cookie.Name = envService.JwtCookie()
 						cookie.Value = token
-						cookie.Expires = time.Now().Add(time.Duration(envService.JWTLifetime()) * time.Second)
+						cookie.Expires = time.Now().Add(envService.JwtLifeTime() * time.Second)
 						fctx.Cookie(cookie)
 
 						return fctx.Redirect("/admin")
@@ -73,7 +73,7 @@ func LogoutHandler(
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		cookie := new(fiber.Cookie)
-		cookie.Name = envService.JWTCookie()
+		cookie.Name = envService.JwtCookie()
 		cookie.Expires = time.Now().Add(-1 * time.Second)
 		fctx.Cookie(cookie)
 

+ 3 - 4
internal/services/handler/admin/tag.go

@@ -9,6 +9,7 @@ import (
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/mapper"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/handler"
 )
 
 const errTagExists = "Тег с данным URL уже существует"
@@ -63,8 +64,6 @@ func AddTagHandler(
 					return err
 				}
 
-				cacheService.FlushAll()
-
 				return fctx.Redirect("/admin/tag")
 			}
 		}
@@ -129,7 +128,7 @@ func EditTagHandler(
 					return err
 				}
 
-				cacheService.FlushAll()
+				cacheService.Delete(handler.TagCacheKey + tag.URL)
 
 				return fctx.Redirect("/admin/tag")
 			}
@@ -171,7 +170,7 @@ func DeleteTagHandler(
 				return err
 			}
 
-			cacheService.FlushAll()
+			cacheService.Delete(handler.TagCacheKey + tag.URL)
 
 			return fctx.Redirect("/admin/tag")
 		}

+ 13 - 6
internal/services/handler/article.go

@@ -2,16 +2,18 @@ package handler
 
 import (
 	"database/sql"
+	"errors"
 
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
 	"github.com/gofiber/fiber/v2"
 
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/mapper"
 )
 
 const (
+	ArticleCacheKey  = "article"
 	maxArticlesCount = 3
 	articleParam     = "article"
-	articleCacheKey  = "article"
 )
 
 func ArticleHandler(
@@ -21,16 +23,19 @@ func ArticleHandler(
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
-		articleReq := fctx.Params(articleParam)
+		url := fctx.Params(articleParam)
 
-		renderData, found := cacheService.Get(articleCacheKey + articleReq)
+		renderData, found := cacheService.Get(ArticleCacheKey + url)
 
 		if !found {
-			article, err := articleRepository.GetByURL(ctx, articleReq)
+			article, err := articleRepository.GetByURL(ctx, url)
 			if err != nil {
-				if err == sql.ErrNoRows {
+				if errors.Is(err, sql.ErrNoRows) {
 					return fiber.ErrNotFound
 				}
+
+				logger.Error(ctx, err.Error())
+
 				return err
 			}
 
@@ -43,6 +48,7 @@ func ArticleHandler(
 			// All used tags
 			tags, err := tagRepository.GetAllUsed(ctx)
 			if err != nil {
+				logger.Error(ctx, err.Error())
 				return err
 			}
 
@@ -51,6 +57,7 @@ func ArticleHandler(
 			// Last articles
 			articles, err := articleRepository.GetAllPreview(ctx)
 			if err != nil {
+				logger.Error(ctx, err.Error())
 				return err
 			}
 			if len(articles) > maxArticlesCount {
@@ -67,7 +74,7 @@ func ArticleHandler(
 				"sidebarTags":     tagsDTO,
 			}
 
-			cacheService.Set(articleCacheKey+articleReq, renderData)
+			cacheService.Set(ArticleCacheKey+url, renderData, nil)
 		}
 
 		return fctx.Render("article", renderData, "_layout")

+ 22 - 22
internal/services/handler/article_test.go

@@ -5,7 +5,6 @@ import (
 	"database/sql"
 	"errors"
 	"net/http/httptest"
-	"strconv"
 	"testing"
 
 	"github.com/brianvoe/gofakeit/v6"
@@ -27,13 +26,14 @@ func TestArticleHandler(t *testing.T) {
 	}
 
 	var (
-		articleID   = gofakeit.Number(1, 100)
+		articleID   = gofakeit.Uint64()
+		articleURL  = gofakeit.URL()
 		publishTime = gofakeit.Date()
 		internalErr = errors.New(gofakeit.Phrase())
 
 		article = models.Article{
 			ID:          articleID,
-			URL:         gofakeit.URL(),
+			URL:         articleURL,
 			Title:       gofakeit.Phrase(),
 			Text:        gofakeit.Phrase(),
 			PublishTime: publishTime,
@@ -44,7 +44,7 @@ func TestArticleHandler(t *testing.T) {
 
 		notActiveArticle = models.Article{
 			ID:          articleID,
-			URL:         gofakeit.URL(),
+			URL:         articleURL,
 			Title:       gofakeit.Phrase(),
 			Text:        gofakeit.Phrase(),
 			PublishTime: publishTime,
@@ -54,12 +54,12 @@ func TestArticleHandler(t *testing.T) {
 
 		tags = []models.Tag{
 			{
-				ID:  gofakeit.Number(1, 100),
+				ID:  gofakeit.Uint64(),
 				Tag: gofakeit.Word(),
 				URL: gofakeit.Word(),
 			},
 			{
-				ID:  gofakeit.Number(1, 100),
+				ID:  gofakeit.Uint64(),
 				Tag: gofakeit.Word(),
 				URL: gofakeit.Word(),
 			},
@@ -67,7 +67,7 @@ func TestArticleHandler(t *testing.T) {
 
 		previewArticles = []models.ArticlePreview{
 			{
-				ID:          gofakeit.Number(1, 100),
+				ID:          gofakeit.Uint64(),
 				URL:         gofakeit.URL(),
 				Title:       gofakeit.Phrase(),
 				PublishTime: publishTime,
@@ -75,7 +75,7 @@ func TestArticleHandler(t *testing.T) {
 				Image:       sql.NullString{Valid: true, String: gofakeit.URL()},
 			},
 			{
-				ID:          gofakeit.Number(1, 100),
+				ID:          gofakeit.Uint64(),
 				URL:         gofakeit.URL(),
 				Title:       gofakeit.Phrase(),
 				PublishTime: publishTime,
@@ -83,7 +83,7 @@ func TestArticleHandler(t *testing.T) {
 				Image:       sql.NullString{Valid: true, String: gofakeit.URL()},
 			},
 			{
-				ID:          gofakeit.Number(1, 100),
+				ID:          gofakeit.Uint64(),
 				URL:         gofakeit.URL(),
 				Title:       gofakeit.Phrase(),
 				PublishTime: publishTime,
@@ -91,7 +91,7 @@ func TestArticleHandler(t *testing.T) {
 				Image:       sql.NullString{Valid: true, String: gofakeit.URL()},
 			},
 			{
-				ID:          gofakeit.Number(1, 100),
+				ID:          gofakeit.Uint64(),
 				URL:         gofakeit.URL(),
 				Title:       gofakeit.Phrase(),
 				PublishTime: publishTime,
@@ -114,7 +114,7 @@ func TestArticleHandler(t *testing.T) {
 			name: "positive case",
 			req: req{
 				method: fiber.MethodGet,
-				route:  "/article/" + strconv.Itoa(articleID),
+				route:  "/article/" + articleURL,
 			},
 			res: fiber.StatusOK,
 			err: nil,
@@ -135,7 +135,7 @@ func TestArticleHandler(t *testing.T) {
 				mock := mocks.NewArticleRepositoryMock(mc)
 
 				mock.GetByURLMock.Inspect(func(ctx context.Context, url string) {
-					assert.Equal(mc, strconv.Itoa(articleID), url)
+					assert.Equal(mc, articleURL, url)
 				}).Return(&article, nil)
 
 				mock.GetAllPreviewMock.Return(previewArticles, nil)
@@ -147,7 +147,7 @@ func TestArticleHandler(t *testing.T) {
 			name: "negative case - article not found",
 			req: req{
 				method: fiber.MethodGet,
-				route:  "/article/" + strconv.Itoa(articleID),
+				route:  "/article/" + articleURL,
 			},
 			res: fiber.StatusNotFound,
 			err: nil,
@@ -165,7 +165,7 @@ func TestArticleHandler(t *testing.T) {
 			articleMock: func(mc *minimock.Controller) ArticleRepository {
 				mock := mocks.NewArticleRepositoryMock(mc)
 				mock.GetByURLMock.Inspect(func(ctx context.Context, url string) {
-					assert.Equal(mc, strconv.Itoa(articleID), url)
+					assert.Equal(mc, articleURL, url)
 				}).Return(nil, sql.ErrNoRows)
 
 				return mock
@@ -175,7 +175,7 @@ func TestArticleHandler(t *testing.T) {
 			name: "negative case - article repository error",
 			req: req{
 				method: fiber.MethodGet,
-				route:  "/article/" + strconv.Itoa(articleID),
+				route:  "/article/" + articleURL,
 			},
 			res: fiber.StatusInternalServerError,
 			err: nil,
@@ -193,7 +193,7 @@ func TestArticleHandler(t *testing.T) {
 			articleMock: func(mc *minimock.Controller) ArticleRepository {
 				mock := mocks.NewArticleRepositoryMock(mc)
 				mock.GetByURLMock.Inspect(func(ctx context.Context, url string) {
-					assert.Equal(mc, strconv.Itoa(articleID), url)
+					assert.Equal(mc, articleURL, url)
 				}).Return(nil, internalErr)
 
 				return mock
@@ -203,7 +203,7 @@ func TestArticleHandler(t *testing.T) {
 			name: "negative case - article not active",
 			req: req{
 				method: fiber.MethodGet,
-				route:  "/article/" + strconv.Itoa(articleID),
+				route:  "/article/" + articleURL,
 			},
 			res: fiber.StatusNotFound,
 			err: nil,
@@ -221,7 +221,7 @@ func TestArticleHandler(t *testing.T) {
 			articleMock: func(mc *minimock.Controller) ArticleRepository {
 				mock := mocks.NewArticleRepositoryMock(mc)
 				mock.GetByURLMock.Inspect(func(ctx context.Context, url string) {
-					assert.Equal(mc, strconv.Itoa(articleID), url)
+					assert.Equal(mc, articleURL, url)
 				}).Return(&notActiveArticle, nil)
 
 				return mock
@@ -231,7 +231,7 @@ func TestArticleHandler(t *testing.T) {
 			name: "negative case - tags repository error",
 			req: req{
 				method: fiber.MethodGet,
-				route:  "/article/" + strconv.Itoa(articleID),
+				route:  "/article/" + articleURL,
 			},
 			res: fiber.StatusInternalServerError,
 			err: nil,
@@ -251,7 +251,7 @@ func TestArticleHandler(t *testing.T) {
 				mock := mocks.NewArticleRepositoryMock(mc)
 
 				mock.GetByURLMock.Inspect(func(ctx context.Context, url string) {
-					assert.Equal(mc, strconv.Itoa(articleID), url)
+					assert.Equal(mc, articleURL, url)
 				}).Return(&article, nil)
 
 				return mock
@@ -261,7 +261,7 @@ func TestArticleHandler(t *testing.T) {
 			name: "negative case - articles repository error",
 			req: req{
 				method: fiber.MethodGet,
-				route:  "/article/" + strconv.Itoa(articleID),
+				route:  "/article/" + articleURL,
 			},
 			res: fiber.StatusInternalServerError,
 			err: nil,
@@ -281,7 +281,7 @@ func TestArticleHandler(t *testing.T) {
 				mock := mocks.NewArticleRepositoryMock(mc)
 
 				mock.GetByURLMock.Inspect(func(ctx context.Context, url string) {
-					assert.Equal(mc, strconv.Itoa(articleID), url)
+					assert.Equal(mc, articleURL, url)
 				}).Return(&article, nil)
 
 				mock.GetAllPreviewMock.Return(nil, internalErr)

+ 7 - 3
internal/services/handler/main_page.go

@@ -6,7 +6,9 @@ package handler
 
 import (
 	"context"
+	"time"
 
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
 	"github.com/gofiber/fiber/v2"
 
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/mapper"
@@ -16,13 +18,13 @@ import (
 type (
 	CacheService interface {
 		Get(key string) (interface{}, bool)
-		Set(key string, value interface{})
+		Set(key string, value interface{}, expiration *time.Duration)
 	}
 
 	ArticleRepository interface {
 		GetByURL(ctx context.Context, url string) (*models.Article, error)
 		GetAllPreview(ctx context.Context) ([]models.ArticlePreview, error)
-		GetPreviewByTagID(ctx context.Context, tagID int) ([]models.ArticlePreview, error)
+		GetPreviewByTagID(ctx context.Context, tagID uint64) ([]models.ArticlePreview, error)
 	}
 
 	TagRepository interface {
@@ -38,11 +40,13 @@ func MainPageHandler(
 	articleRepository ArticleRepository,
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
+		ctx := fctx.Context()
 		renderData, found := cacheService.Get(allPreviewArticlesCacheKey)
 
 		if !found {
 			articles, err := articleRepository.GetAllPreview(fctx.Context())
 			if err != nil {
+				logger.Error(ctx, err.Error())
 				return err
 			}
 
@@ -54,7 +58,7 @@ func MainPageHandler(
 				"articles":        mapper.ToArticlePreviewDTOList(articles),
 			}
 
-			cacheService.Set(allPreviewArticlesCacheKey, renderData)
+			cacheService.Set(allPreviewArticlesCacheKey, renderData, nil)
 		}
 
 		return fctx.Render("index", renderData, "_layout")

+ 6 - 3
internal/services/handler/main_page_test.go

@@ -6,6 +6,7 @@ import (
 	"net/http/httptest"
 	"testing"
 
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
 	"github.com/brianvoe/gofakeit/v6"
 	"github.com/gofiber/fiber/v2"
 	"github.com/gojuno/minimock/v3"
@@ -17,6 +18,8 @@ import (
 )
 
 func TestMainPageHandler(t *testing.T) {
+	logger.Init(logger.Config{})
+
 	t.Parallel()
 
 	type req struct {
@@ -30,7 +33,7 @@ func TestMainPageHandler(t *testing.T) {
 
 		previewArticles = []models.ArticlePreview{
 			{
-				ID:          gofakeit.Number(1, 100),
+				ID:          gofakeit.Uint64(),
 				URL:         gofakeit.URL(),
 				Title:       gofakeit.Phrase(),
 				PublishTime: publishTime,
@@ -38,7 +41,7 @@ func TestMainPageHandler(t *testing.T) {
 				Image:       sql.NullString{Valid: true, String: gofakeit.URL()},
 			},
 			{
-				ID:          gofakeit.Number(1, 100),
+				ID:          gofakeit.Uint64(),
 				URL:         gofakeit.URL(),
 				Title:       gofakeit.Phrase(),
 				PublishTime: publishTime,
@@ -46,7 +49,7 @@ func TestMainPageHandler(t *testing.T) {
 				Image:       sql.NullString{Valid: true, String: gofakeit.URL()},
 			},
 			{
-				ID:          gofakeit.Number(1, 100),
+				ID:          gofakeit.Uint64(),
 				URL:         gofakeit.URL(),
 				Title:       gofakeit.Phrase(),
 				PublishTime: publishTime,

+ 10 - 10
internal/services/handler/mocks/article_repository_minimock.go

@@ -31,8 +31,8 @@ type ArticleRepositoryMock struct {
 	beforeGetByURLCounter uint64
 	GetByURLMock          mArticleRepositoryMockGetByURL
 
-	funcGetPreviewByTagID          func(ctx context.Context, tagID int) (aa1 []models.ArticlePreview, err error)
-	inspectFuncGetPreviewByTagID   func(ctx context.Context, tagID int)
+	funcGetPreviewByTagID          func(ctx context.Context, tagID uint64) (aa1 []models.ArticlePreview, err error)
+	inspectFuncGetPreviewByTagID   func(ctx context.Context, tagID uint64)
 	afterGetPreviewByTagIDCounter  uint64
 	beforeGetPreviewByTagIDCounter uint64
 	GetPreviewByTagIDMock          mArticleRepositoryMockGetPreviewByTagID
@@ -698,13 +698,13 @@ type ArticleRepositoryMockGetPreviewByTagIDExpectation struct {
 // ArticleRepositoryMockGetPreviewByTagIDParams contains parameters of the ArticleRepository.GetPreviewByTagID
 type ArticleRepositoryMockGetPreviewByTagIDParams struct {
 	ctx   context.Context
-	tagID int
+	tagID uint64
 }
 
 // ArticleRepositoryMockGetPreviewByTagIDParamPtrs contains pointers to parameters of the ArticleRepository.GetPreviewByTagID
 type ArticleRepositoryMockGetPreviewByTagIDParamPtrs struct {
 	ctx   *context.Context
-	tagID *int
+	tagID *uint64
 }
 
 // ArticleRepositoryMockGetPreviewByTagIDResults contains results of the ArticleRepository.GetPreviewByTagID
@@ -724,7 +724,7 @@ func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) Optional() *
 }
 
 // Expect sets up expected params for ArticleRepository.GetPreviewByTagID
-func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) Expect(ctx context.Context, tagID int) *mArticleRepositoryMockGetPreviewByTagID {
+func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) Expect(ctx context.Context, tagID uint64) *mArticleRepositoryMockGetPreviewByTagID {
 	if mmGetPreviewByTagID.mock.funcGetPreviewByTagID != nil {
 		mmGetPreviewByTagID.mock.t.Fatalf("ArticleRepositoryMock.GetPreviewByTagID mock is already set by Set")
 	}
@@ -770,7 +770,7 @@ func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) ExpectCtxPar
 }
 
 // ExpectTagIDParam2 sets up expected param tagID for ArticleRepository.GetPreviewByTagID
-func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) ExpectTagIDParam2(tagID int) *mArticleRepositoryMockGetPreviewByTagID {
+func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) ExpectTagIDParam2(tagID uint64) *mArticleRepositoryMockGetPreviewByTagID {
 	if mmGetPreviewByTagID.mock.funcGetPreviewByTagID != nil {
 		mmGetPreviewByTagID.mock.t.Fatalf("ArticleRepositoryMock.GetPreviewByTagID mock is already set by Set")
 	}
@@ -792,7 +792,7 @@ func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) ExpectTagIDP
 }
 
 // Inspect accepts an inspector function that has same arguments as the ArticleRepository.GetPreviewByTagID
-func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) Inspect(f func(ctx context.Context, tagID int)) *mArticleRepositoryMockGetPreviewByTagID {
+func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) Inspect(f func(ctx context.Context, tagID uint64)) *mArticleRepositoryMockGetPreviewByTagID {
 	if mmGetPreviewByTagID.mock.inspectFuncGetPreviewByTagID != nil {
 		mmGetPreviewByTagID.mock.t.Fatalf("Inspect function is already set for ArticleRepositoryMock.GetPreviewByTagID")
 	}
@@ -816,7 +816,7 @@ func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) Return(aa1 [
 }
 
 // Set uses given function f to mock the ArticleRepository.GetPreviewByTagID method
-func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) Set(f func(ctx context.Context, tagID int) (aa1 []models.ArticlePreview, err error)) *ArticleRepositoryMock {
+func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) Set(f func(ctx context.Context, tagID uint64) (aa1 []models.ArticlePreview, err error)) *ArticleRepositoryMock {
 	if mmGetPreviewByTagID.defaultExpectation != nil {
 		mmGetPreviewByTagID.mock.t.Fatalf("Default expectation is already set for the ArticleRepository.GetPreviewByTagID method")
 	}
@@ -831,7 +831,7 @@ func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) Set(f func(c
 
 // When sets expectation for the ArticleRepository.GetPreviewByTagID which will trigger the result defined by the following
 // Then helper
-func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) When(ctx context.Context, tagID int) *ArticleRepositoryMockGetPreviewByTagIDExpectation {
+func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) When(ctx context.Context, tagID uint64) *ArticleRepositoryMockGetPreviewByTagIDExpectation {
 	if mmGetPreviewByTagID.mock.funcGetPreviewByTagID != nil {
 		mmGetPreviewByTagID.mock.t.Fatalf("ArticleRepositoryMock.GetPreviewByTagID mock is already set by Set")
 	}
@@ -871,7 +871,7 @@ func (mmGetPreviewByTagID *mArticleRepositoryMockGetPreviewByTagID) invocationsD
 }
 
 // GetPreviewByTagID implements handler.ArticleRepository
-func (mmGetPreviewByTagID *ArticleRepositoryMock) GetPreviewByTagID(ctx context.Context, tagID int) (aa1 []models.ArticlePreview, err error) {
+func (mmGetPreviewByTagID *ArticleRepositoryMock) GetPreviewByTagID(ctx context.Context, tagID uint64) (aa1 []models.ArticlePreview, err error) {
 	mm_atomic.AddUint64(&mmGetPreviewByTagID.beforeGetPreviewByTagIDCounter, 1)
 	defer mm_atomic.AddUint64(&mmGetPreviewByTagID.afterGetPreviewByTagIDCounter, 1)
 

+ 45 - 16
internal/services/handler/mocks/cache_service_minimock.go

@@ -7,6 +7,7 @@ package mocks
 import (
 	"sync"
 	mm_atomic "sync/atomic"
+	"time"
 	mm_time "time"
 
 	"github.com/gojuno/minimock/v3"
@@ -23,8 +24,8 @@ type CacheServiceMock struct {
 	beforeGetCounter uint64
 	GetMock          mCacheServiceMockGet
 
-	funcSet          func(key string, value interface{})
-	inspectFuncSet   func(key string, value interface{})
+	funcSet          func(key string, value interface{}, expiration *time.Duration)
+	inspectFuncSet   func(key string, value interface{}, expiration *time.Duration)
 	afterSetCounter  uint64
 	beforeSetCounter uint64
 	SetMock          mCacheServiceMockSet
@@ -365,14 +366,16 @@ type CacheServiceMockSetExpectation struct {
 
 // CacheServiceMockSetParams contains parameters of the CacheService.Set
 type CacheServiceMockSetParams struct {
-	key   string
-	value interface{}
+	key        string
+	value      interface{}
+	expiration *time.Duration
 }
 
 // CacheServiceMockSetParamPtrs contains pointers to parameters of the CacheService.Set
 type CacheServiceMockSetParamPtrs struct {
-	key   *string
-	value *interface{}
+	key        *string
+	value      *interface{}
+	expiration **time.Duration
 }
 
 // Marks this method to be optional. The default behavior of any method with Return() is '1 or more', meaning
@@ -386,7 +389,7 @@ func (mmSet *mCacheServiceMockSet) Optional() *mCacheServiceMockSet {
 }
 
 // Expect sets up expected params for CacheService.Set
-func (mmSet *mCacheServiceMockSet) Expect(key string, value interface{}) *mCacheServiceMockSet {
+func (mmSet *mCacheServiceMockSet) Expect(key string, value interface{}, expiration *time.Duration) *mCacheServiceMockSet {
 	if mmSet.mock.funcSet != nil {
 		mmSet.mock.t.Fatalf("CacheServiceMock.Set mock is already set by Set")
 	}
@@ -399,7 +402,7 @@ func (mmSet *mCacheServiceMockSet) Expect(key string, value interface{}) *mCache
 		mmSet.mock.t.Fatalf("CacheServiceMock.Set mock is already set by ExpectParams functions")
 	}
 
-	mmSet.defaultExpectation.params = &CacheServiceMockSetParams{key, value}
+	mmSet.defaultExpectation.params = &CacheServiceMockSetParams{key, value, expiration}
 	for _, e := range mmSet.expectations {
 		if minimock.Equal(e.params, mmSet.defaultExpectation.params) {
 			mmSet.mock.t.Fatalf("Expectation set by When has same params: %#v", *mmSet.defaultExpectation.params)
@@ -453,8 +456,30 @@ func (mmSet *mCacheServiceMockSet) ExpectValueParam2(value interface{}) *mCacheS
 	return mmSet
 }
 
+// ExpectExpirationParam3 sets up expected param expiration for CacheService.Set
+func (mmSet *mCacheServiceMockSet) ExpectExpirationParam3(expiration *time.Duration) *mCacheServiceMockSet {
+	if mmSet.mock.funcSet != nil {
+		mmSet.mock.t.Fatalf("CacheServiceMock.Set mock is already set by Set")
+	}
+
+	if mmSet.defaultExpectation == nil {
+		mmSet.defaultExpectation = &CacheServiceMockSetExpectation{}
+	}
+
+	if mmSet.defaultExpectation.params != nil {
+		mmSet.mock.t.Fatalf("CacheServiceMock.Set mock is already set by Expect")
+	}
+
+	if mmSet.defaultExpectation.paramPtrs == nil {
+		mmSet.defaultExpectation.paramPtrs = &CacheServiceMockSetParamPtrs{}
+	}
+	mmSet.defaultExpectation.paramPtrs.expiration = &expiration
+
+	return mmSet
+}
+
 // Inspect accepts an inspector function that has same arguments as the CacheService.Set
-func (mmSet *mCacheServiceMockSet) Inspect(f func(key string, value interface{})) *mCacheServiceMockSet {
+func (mmSet *mCacheServiceMockSet) Inspect(f func(key string, value interface{}, expiration *time.Duration)) *mCacheServiceMockSet {
 	if mmSet.mock.inspectFuncSet != nil {
 		mmSet.mock.t.Fatalf("Inspect function is already set for CacheServiceMock.Set")
 	}
@@ -478,7 +503,7 @@ func (mmSet *mCacheServiceMockSet) Return() *CacheServiceMock {
 }
 
 // Set uses given function f to mock the CacheService.Set method
-func (mmSet *mCacheServiceMockSet) Set(f func(key string, value interface{})) *CacheServiceMock {
+func (mmSet *mCacheServiceMockSet) Set(f func(key string, value interface{}, expiration *time.Duration)) *CacheServiceMock {
 	if mmSet.defaultExpectation != nil {
 		mmSet.mock.t.Fatalf("Default expectation is already set for the CacheService.Set method")
 	}
@@ -512,15 +537,15 @@ func (mmSet *mCacheServiceMockSet) invocationsDone() bool {
 }
 
 // Set implements handler.CacheService
-func (mmSet *CacheServiceMock) Set(key string, value interface{}) {
+func (mmSet *CacheServiceMock) Set(key string, value interface{}, expiration *time.Duration) {
 	mm_atomic.AddUint64(&mmSet.beforeSetCounter, 1)
 	defer mm_atomic.AddUint64(&mmSet.afterSetCounter, 1)
 
 	if mmSet.inspectFuncSet != nil {
-		mmSet.inspectFuncSet(key, value)
+		mmSet.inspectFuncSet(key, value, expiration)
 	}
 
-	mm_params := CacheServiceMockSetParams{key, value}
+	mm_params := CacheServiceMockSetParams{key, value, expiration}
 
 	// Record call args
 	mmSet.SetMock.mutex.Lock()
@@ -539,7 +564,7 @@ func (mmSet *CacheServiceMock) Set(key string, value interface{}) {
 		mm_want := mmSet.SetMock.defaultExpectation.params
 		mm_want_ptrs := mmSet.SetMock.defaultExpectation.paramPtrs
 
-		mm_got := CacheServiceMockSetParams{key, value}
+		mm_got := CacheServiceMockSetParams{key, value, expiration}
 
 		if mm_want_ptrs != nil {
 
@@ -551,6 +576,10 @@ func (mmSet *CacheServiceMock) Set(key string, value interface{}) {
 				mmSet.t.Errorf("CacheServiceMock.Set got unexpected parameter value, want: %#v, got: %#v%s\n", *mm_want_ptrs.value, mm_got.value, minimock.Diff(*mm_want_ptrs.value, mm_got.value))
 			}
 
+			if mm_want_ptrs.expiration != nil && !minimock.Equal(*mm_want_ptrs.expiration, mm_got.expiration) {
+				mmSet.t.Errorf("CacheServiceMock.Set got unexpected parameter expiration, want: %#v, got: %#v%s\n", *mm_want_ptrs.expiration, mm_got.expiration, minimock.Diff(*mm_want_ptrs.expiration, mm_got.expiration))
+			}
+
 		} else if mm_want != nil && !minimock.Equal(*mm_want, mm_got) {
 			mmSet.t.Errorf("CacheServiceMock.Set got unexpected parameters, want: %#v, got: %#v%s\n", *mm_want, mm_got, minimock.Diff(*mm_want, mm_got))
 		}
@@ -559,10 +588,10 @@ func (mmSet *CacheServiceMock) Set(key string, value interface{}) {
 
 	}
 	if mmSet.funcSet != nil {
-		mmSet.funcSet(key, value)
+		mmSet.funcSet(key, value, expiration)
 		return
 	}
-	mmSet.t.Fatalf("Unexpected call to CacheServiceMock.Set. %v %v", key, value)
+	mmSet.t.Fatalf("Unexpected call to CacheServiceMock.Set. %v %v %v", key, value, expiration)
 
 }
 

+ 15 - 7
internal/services/handler/tag.go

@@ -2,14 +2,18 @@ package handler
 
 import (
 	"database/sql"
+	"errors"
 
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
 	"github.com/gofiber/fiber/v2"
 
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/mapper"
 )
 
-const tagParam = "tag"
-const tagCacheKey = "tag"
+const (
+	TagCacheKey = "tag"
+	tagParam    = "tag"
+)
 
 func TagHandler(
 	cacheService CacheService,
@@ -18,21 +22,25 @@ func TagHandler(
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
-		tagReq := fctx.Params(tagParam)
+		url := fctx.Params(tagParam)
 
-		renderData, found := cacheService.Get(tagCacheKey + tagReq)
+		renderData, found := cacheService.Get(TagCacheKey + url)
 
 		if !found {
-			tag, err := tagRepository.GetByURL(ctx, tagReq)
+			tag, err := tagRepository.GetByURL(ctx, url)
 			if err != nil {
-				if err == sql.ErrNoRows {
+				if errors.Is(err, sql.ErrNoRows) {
 					return fiber.ErrNotFound
 				}
+
+				logger.Error(ctx, err.Error())
+
 				return err
 			}
 
 			articles, err := articleRepository.GetPreviewByTagID(ctx, tag.ID)
 			if err != nil {
+				logger.Error(ctx, err.Error())
 				return err
 			}
 
@@ -44,7 +52,7 @@ func TagHandler(
 				"articles":        mapper.ToArticlePreviewDTOList(articles),
 			}
 
-			cacheService.Set(tagCacheKey+tagReq, renderData)
+			cacheService.Set(TagCacheKey+url, renderData, nil)
 		}
 
 		return fctx.Render("tag", renderData, "_layout")

+ 16 - 13
internal/services/handler/tag_test.go

@@ -5,9 +5,9 @@ import (
 	"database/sql"
 	"errors"
 	"net/http/httptest"
-	"strconv"
 	"testing"
 
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
 	"github.com/brianvoe/gofakeit/v6"
 	"github.com/gofiber/fiber/v2"
 	"github.com/gojuno/minimock/v3"
@@ -19,6 +19,8 @@ import (
 )
 
 func TestTagHandler(t *testing.T) {
+	logger.Init(logger.Config{})
+
 	t.Parallel()
 
 	type req struct {
@@ -27,7 +29,8 @@ func TestTagHandler(t *testing.T) {
 	}
 
 	var (
-		tagID       = gofakeit.Number(1, 100)
+		tagID       = gofakeit.Uint64()
+		tagURL      = gofakeit.URL()
 		publishTime = gofakeit.Date()
 		internalErr = errors.New(gofakeit.Phrase())
 
@@ -36,7 +39,7 @@ func TestTagHandler(t *testing.T) {
 		}
 
 		article = models.ArticlePreview{
-			ID:          gofakeit.Number(1, 100),
+			ID:          gofakeit.Uint64(),
 			URL:         gofakeit.URL(),
 			Title:       gofakeit.Phrase(),
 			PublishTime: publishTime,
@@ -58,7 +61,7 @@ func TestTagHandler(t *testing.T) {
 			name: "positive case",
 			req: req{
 				method: fiber.MethodGet,
-				route:  "/tag/" + strconv.Itoa(tagID),
+				route:  "/tag/" + tagURL,
 			},
 			res: fiber.StatusOK,
 			err: nil,
@@ -72,7 +75,7 @@ func TestTagHandler(t *testing.T) {
 			tagMock: func(mc *minimock.Controller) TagRepository {
 				mock := mocks.NewTagRepositoryMock(mc)
 				mock.GetByURLMock.Inspect(func(ctx context.Context, url string) {
-					assert.Equal(mc, strconv.Itoa(tagID), url)
+					assert.Equal(mc, tagURL, url)
 				}).Return(&tag, nil)
 
 				return mock
@@ -80,7 +83,7 @@ func TestTagHandler(t *testing.T) {
 			},
 			articleMock: func(mc *minimock.Controller) ArticleRepository {
 				mock := mocks.NewArticleRepositoryMock(mc)
-				mock.GetPreviewByTagIDMock.Inspect(func(ctx context.Context, id int) {
+				mock.GetPreviewByTagIDMock.Inspect(func(ctx context.Context, id uint64) {
 					assert.Equal(mc, tagID, id)
 				}).Return([]models.ArticlePreview{article}, nil)
 
@@ -91,7 +94,7 @@ func TestTagHandler(t *testing.T) {
 			name: "negative case - tag not found",
 			req: req{
 				method: fiber.MethodGet,
-				route:  "/tag/" + strconv.Itoa(tagID),
+				route:  "/tag/" + tagURL,
 			},
 			res: fiber.StatusNotFound,
 			err: nil,
@@ -104,7 +107,7 @@ func TestTagHandler(t *testing.T) {
 			tagMock: func(mc *minimock.Controller) TagRepository {
 				mock := mocks.NewTagRepositoryMock(mc)
 				mock.GetByURLMock.Inspect(func(ctx context.Context, url string) {
-					assert.Equal(mc, strconv.Itoa(tagID), url)
+					assert.Equal(mc, tagURL, url)
 				}).Return(nil, sql.ErrNoRows)
 
 				return mock
@@ -118,7 +121,7 @@ func TestTagHandler(t *testing.T) {
 			name: "negative case - tag repository error",
 			req: req{
 				method: fiber.MethodGet,
-				route:  "/tag/" + strconv.Itoa(tagID),
+				route:  "/tag/" + tagURL,
 			},
 			res: fiber.StatusInternalServerError,
 			err: nil,
@@ -131,7 +134,7 @@ func TestTagHandler(t *testing.T) {
 			tagMock: func(mc *minimock.Controller) TagRepository {
 				mock := mocks.NewTagRepositoryMock(mc)
 				mock.GetByURLMock.Inspect(func(ctx context.Context, url string) {
-					assert.Equal(mc, strconv.Itoa(tagID), url)
+					assert.Equal(mc, tagURL, url)
 				}).Return(nil, internalErr)
 
 				return mock
@@ -144,7 +147,7 @@ func TestTagHandler(t *testing.T) {
 			name: "negative case - article repository error",
 			req: req{
 				method: fiber.MethodGet,
-				route:  "/tag/" + strconv.Itoa(tagID),
+				route:  "/tag/" + tagURL,
 			},
 			res: fiber.StatusInternalServerError,
 			err: nil,
@@ -157,7 +160,7 @@ func TestTagHandler(t *testing.T) {
 			tagMock: func(mc *minimock.Controller) TagRepository {
 				mock := mocks.NewTagRepositoryMock(mc)
 				mock.GetByURLMock.Inspect(func(ctx context.Context, url string) {
-					assert.Equal(mc, strconv.Itoa(tagID), url)
+					assert.Equal(mc, tagURL, url)
 				}).Return(&tag, nil)
 
 				return mock
@@ -165,7 +168,7 @@ func TestTagHandler(t *testing.T) {
 			},
 			articleMock: func(mc *minimock.Controller) ArticleRepository {
 				mock := mocks.NewArticleRepositoryMock(mc)
-				mock.GetPreviewByTagIDMock.Inspect(func(ctx context.Context, id int) {
+				mock.GetPreviewByTagIDMock.Inspect(func(ctx context.Context, id uint64) {
 					assert.Equal(mc, tagID, id)
 				}).Return(nil, internalErr)
 

+ 0 - 90
internal/services/mailer/mailer.go

@@ -1,90 +0,0 @@
-package mailer
-
-import (
-	"fmt"
-	"net/smtp"
-	"strings"
-)
-
-type Env interface {
-	SMTPHost() string
-	SMTPPort() string
-	SMTPUser() string
-	SMTPPassword() string
-}
-
-type Service struct {
-	isEnabled bool
-	host      string
-	port      string
-	user      string
-	password  string
-}
-
-type mailerAuth struct {
-	username string
-	password string
-}
-
-func Init(env Env) (*Service, error) {
-	host := strings.TrimSpace(env.SMTPHost())
-	port := strings.TrimSpace(env.SMTPPort())
-	user := strings.TrimSpace(env.SMTPUser())
-	password := strings.TrimSpace(env.SMTPPassword())
-
-	if host == "" || port == "" || user == "" || password == "" {
-		return &Service{}, nil
-	}
-
-	return &Service{
-		isEnabled: true,
-		host:      host,
-		port:      port,
-		user:      user,
-		password:  password,
-	}, nil
-}
-
-func (m Service) Send(recipient string, subject string, text string) error {
-	if !m.isEnabled {
-		return nil
-	}
-
-	msg := []byte("To: " + recipient + "\r\n" +
-		"From: " + m.user + "\r\n" +
-		"Subject: " + subject + "\r\n" +
-		"Content-Type: text/plain; charset=\"UTF-8\"" + "\n\r\n" +
-		text + "\r\n")
-
-	to := []string{recipient}
-	auth := m.GetMailerAuth(m.user, m.password)
-	return smtp.SendMail(m.host+":"+m.port, auth, m.user, to, msg)
-}
-
-func (m Service) GetMailerAuth(username, password string) smtp.Auth {
-	return &mailerAuth{username, password}
-}
-
-func (a *mailerAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
-	return "LOGIN", nil, nil
-}
-
-func (a *mailerAuth) Next(fromServer []byte, more bool) ([]byte, error) {
-	command := string(fromServer)
-	command = strings.TrimSpace(command)
-	command = strings.TrimSuffix(command, ":")
-	command = strings.ToLower(command)
-
-	if more {
-		if command == "username" {
-			return []byte(a.username), nil
-		}
-		if command == "password" {
-			return []byte(a.password), nil
-		}
-
-		return nil, fmt.Errorf("unexpected server challenge: %s", command)
-	}
-
-	return nil, nil
-}

+ 0 - 12
internal/templates/_ga.html

@@ -1,12 +0,0 @@
-{{ define "_ga" }}
-    {{ if ga }}
-    <script>
-        (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
-            (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
-            m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
-        })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
-        ga('create', {{ ga }}, 'auto');
-        ga('send', 'pageview');
-    </script>
-    {{ end }}
-{{ end }}

+ 0 - 3
internal/templates/_layout.html

@@ -11,9 +11,6 @@
     <link href="/static/app/css/style.css?v={{ $v }}" rel="stylesheet">
     <link href="/static/app/css/color-red.css?v={{ $v }}" rel="stylesheet">
     {{ block "custom_head_static" . }}{{ end }}
-    {{ if ga }}
-    {{ template "_ga" . }}
-    {{ end }}
 </head>
 <body>
 <section class="page-cover" id="home">

+ 32 - 16
readme.md

@@ -3,39 +3,55 @@
 config/.env
 
 ```
+# APP
 APP_PORT=8080
+APP_CORS_ALLOW_ORIGIN=*
+APP_CORS_ALLOW_METHODS=GET,POST,PUT,DELETE
 
+# DB
+DB_DRIVER=postgres
 DB_HOST=localhost
-DB_PORT=5432
+DB_PORT=1111
 DB_NAME=db
 DB_USER=user
 DB_PASSWORD=pass
-
+DB_SSL_MODE=disable
 DB_MAX_OPEN_CONNS=0
-DB_MAX_IDLE_CONNS=5
-DB_MAX_CONN_LIFETIME=0
-DB_MAX_IDLE_CONN_LIFETIME=300
-
-CORS_ALLOW_ORIGING=*
-CORS_ALLOW_METHODS=GET,POST,PUT,DELETE
+DB_MAX_IDLE_CONNS=0
+DB_MAX_OPEN_CONN_LIFETIME=0s
+DB_MAX_IDLE_CONN_LIFETIME=0s
 
-SMTP_HOST=smtp.example.com
-SMTP_PORT=25
-SMTP_USER=example@example.com
-SMTP_PASSWORD=5cCd5m2
+# Cache
+CACHE_DEFAULT_EXPIRATION=24h
+CACHE_CLEANUP_INTERVAL=1h
 
-ERRORS_EMAIL=example@example.com
+# SMTP
+SMTP_HOST=smtp.ru
+SMTP_PORT=2525
+SMTP_USER=example@user.ru
+SMTP_PASSWORD=pass
 
+# JWT
 JWT_SECRET_KEY=test_secret
-JWT_COOKIE=token
-JWT_LIFETIME=21600
+JWT_COOKIE=cookie
+JWT_LIFETIME=1h
 
+# Basic auth
 BASIC_AUTH_USER=user
 BASIC_AUTH_PASSWORD=pass
 
+# Static
 STATIC_VERSION=1
 
-GA_KEY=UA-1111111-1
+# Logger
+LOGGER_STDOUT_ENABLED=true
+LOGGER_STDOUT_LEVEL=info
+LOGGER_STDOUT_ADD_SOURCE=false
+LOGGER_EMAIL_ENABLED=true
+LOGGER_EMAIL_LEVEL=error
+LOGGER_EMAIL_ADD_SOURCE=true
+LOGGER_EMAIL=info@dmitriygnatenko.ru
+LOGGER_EMAIL_SUBJECT=Error from dmitriygnatenko.ru
 ```
 
 ### Команды