소스 검색

Refactoring

Dima 3 달 전
부모
커밋
02a92017e3
50개의 변경된 파일1385개의 추가작업 그리고 1478개의 파일을 삭제
  1. 5 2
      Makefile
  2. BIN
      build/app/app
  3. 2 1
      cmd/app/main.go
  4. 21 19
      go.mod
  5. 63 380
      go.sum
  6. 16 0
      init/systemd/dmitriygnatenko.service
  7. 2 2
      internal/dto/article.go
  8. 1 1
      internal/dto/tag.go
  9. 60 68
      internal/fiber/fiber.go
  10. 0 54
      internal/helpers/datetime.go
  11. 32 0
      internal/helpers/datetime/datetime.go
  12. 1 1
      internal/helpers/i18n/trans.go
  13. 1 1
      internal/helpers/i18n/validate.go
  14. 20 0
      internal/helpers/request/request.go
  15. 2 6
      internal/helpers/test/fiber.go
  16. 15 10
      internal/mapper/article.go
  17. 15 0
      internal/middleware/language/language.go
  18. 18 18
      internal/models/article.go
  19. 4 4
      internal/models/tag.go
  20. 5 5
      internal/models/user.go
  21. 80 91
      internal/repositories/article.go
  22. 27 18
      internal/repositories/article_tag.go
  23. 39 0
      internal/repositories/common.go
  24. 51 94
      internal/repositories/tag.go
  25. 23 22
      internal/repositories/user.go
  26. 90 32
      internal/service_provider/sp.go
  27. 7 7
      internal/services/auth/auth.go
  28. 0 42
      internal/services/cache/cache.go
  29. 293 0
      internal/services/config/config.go
  30. 0 55
      internal/services/db/db.go
  31. 0 208
      internal/services/env/env.go
  32. 124 72
      internal/services/handler/admin/article.go
  33. 9 5
      internal/services/handler/admin/auth.go
  34. 38 26
      internal/services/handler/admin/tag.go
  35. 13 8
      internal/services/handler/admin/user.go
  36. 19 10
      internal/services/handler/article.go
  37. 25 26
      internal/services/handler/article_test.go
  38. 16 9
      internal/services/handler/main_page.go
  39. 5 5
      internal/services/handler/main_page_test.go
  40. 11 11
      internal/services/handler/mocks/article_repository_minimock.go
  41. 46 17
      internal/services/handler/mocks/cache_service_minimock.go
  42. 1 1
      internal/services/handler/mocks/tag_repository_minimock.go
  43. 22 12
      internal/services/handler/tag.go
  44. 15 15
      internal/services/handler/tag_test.go
  45. 77 0
      internal/services/i18n/i18n.go
  46. 34 0
      internal/services/i18n/ru.json
  47. 0 90
      internal/services/mailer/mailer.go
  48. 0 12
      internal/templates/_ga.html
  49. 0 3
      internal/templates/_layout.html
  50. 37 15
      readme.md

+ 5 - 2
Makefile

@@ -23,8 +23,11 @@ test:
 	go test ./...
 
 test-cover:
-	go test ./... -coverprofile=./coverage.out
-	go tool cover -html=./coverage.out
+	go clean -testcache
+	go test ./... -coverprofile=coverage.tmp.out -covermode count -coverpkg=git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/handler/...
+	grep -v 'mocks\|config' coverage.tmp.out  > coverage.out
+	rm coverage.tmp.out
+	go tool cover -html=coverage.out;
 
 lint:
 	golangci-lint run --timeout=3m

BIN
build/app/app


+ 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.ConfigService().AppPort())); err != nil {
 		log.Fatal(err)
 	}
 }

+ 21 - 19
go.mod

@@ -3,54 +3,56 @@ module git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2
 go 1.22.2
 
 require (
+	git.dmitriygnatenko.ru/dima/go-common v1.6.2
 	github.com/Masterminds/squirrel v1.5.3
-	github.com/brianvoe/gofakeit/v6 v6.19.0
+	github.com/brianvoe/gofakeit/v6 v6.28.0
 	github.com/go-playground/locales v0.14.0
 	github.com/go-playground/universal-translator v0.18.0
 	github.com/go-playground/validator/v10 v10.11.1
-	github.com/gofiber/fiber/v2 v2.42.0
+	github.com/gofiber/fiber/v2 v2.52.5
 	github.com/gofiber/jwt/v3 v3.3.6
-	github.com/gofiber/template v1.7.1
+	github.com/gofiber/template/html/v2 v2.1.2
 	github.com/gojuno/minimock/v3 v3.3.13
 	github.com/golang-jwt/jwt/v4 v4.4.3
-	github.com/lib/pq v1.10.7
+	github.com/lib/pq v1.10.9
 	github.com/spf13/viper v1.15.0
-	github.com/stretchr/testify v1.8.4
-	golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e
+	github.com/stretchr/testify v1.9.0
+	golang.org/x/crypto v0.24.0
 )
 
 require (
-	github.com/andybalholm/brotli v1.0.4 // indirect
+	github.com/andybalholm/brotli v1.1.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/fsnotify/fsnotify v1.6.0 // indirect
-	github.com/google/uuid v1.3.0 // indirect
+	github.com/gofiber/template v1.8.3 // indirect
+	github.com/gofiber/utils v1.1.0 // indirect
+	github.com/google/uuid v1.6.0 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
-	github.com/klauspost/compress v1.15.12 // indirect
+	github.com/jmoiron/sqlx v1.4.0 // indirect
+	github.com/klauspost/compress v1.17.9 // indirect
 	github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
 	github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
 	github.com/leodido/go-urn v1.2.1 // indirect
 	github.com/magiconair/properties v1.8.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
-	github.com/mattn/go-isatty v0.0.17 // indirect
-	github.com/mattn/go-runewidth v0.0.14 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-runewidth v0.0.16 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
 	github.com/pelletier/go-toml/v2 v2.0.6 // indirect
-	github.com/philhofer/fwd v1.1.1 // indirect
+	github.com/philhofer/fwd v1.1.2 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/rivo/uniseg v0.4.2 // indirect
-	github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect
-	github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/spf13/afero v1.9.3 // indirect
 	github.com/spf13/cast v1.5.0 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/subosito/gotenv v1.4.2 // indirect
-	github.com/tinylib/msgp v1.1.6 // indirect
+	github.com/tinylib/msgp v1.1.8 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
-	github.com/valyala/fasthttp v1.44.0 // indirect
+	github.com/valyala/fasthttp v1.55.0 // indirect
 	github.com/valyala/tcplisten v1.0.0 // indirect
-	golang.org/x/sys v0.5.0 // indirect
-	golang.org/x/text v0.5.0 // indirect
+	golang.org/x/sys v0.24.0 // indirect
+	golang.org/x/text v0.16.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )

+ 63 - 380
go.sum

@@ -17,18 +17,6 @@ cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHOb
 cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
 cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
 cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
-cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
-cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
-cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
-cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
-cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
-cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
-cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
-cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
-cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
-cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
-cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
-cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@@ -37,7 +25,6 @@ cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4g
 cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
 cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
@@ -49,93 +36,44 @@ 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=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+git.dmitriygnatenko.ru/dima/go-common v1.6.2 h1:AMipagbqaU5VDa9w1alHToy+7Bp4PPMkbui9yWgOqTc=
+git.dmitriygnatenko.ru/dima/go-common v1.6.2/go.mod h1:/7VcyxInOlvAGedhH8YONNpWWETaXFU8gnTxLDLcing=
 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=
-github.com/CloudyKit/jet/v6 v6.1.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4=
-github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
-github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
-github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM=
 github.com/Masterminds/squirrel v1.5.3 h1:YPpoceAcxuzIljlr5iWpNKaql7hLeG1KLSrhvdHpkZc=
 github.com/Masterminds/squirrel v1.5.3/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
-github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
-github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
-github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
-github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/aymerick/raymond v2.0.2+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
-github.com/brianvoe/gofakeit/v6 v6.19.0 h1:g+yJ+meWVEsAmR+bV4mNM/eXI0N+0pZ3D+Mi+G5+YQo=
-github.com/brianvoe/gofakeit/v6 v6.19.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
-github.com/cbroglie/mustache v1.4.0/go.mod h1:SS1FTIghy0sjse4DUVGV1k/40B1qE1XkD9DtDsHo9iM=
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
+github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
-github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
-github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
-github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
-github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
-github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
-github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
-github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8=
 github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
 github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
-github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
 github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
-github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
-github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
 github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
 github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
@@ -144,17 +82,19 @@ github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/j
 github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
 github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
 github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gofiber/fiber/v2 v2.37.1/go.mod h1:j3UslgQeJQP3mNhBxHnLLE8TPqA1Fd/lrl4gD25rRUY=
-github.com/gofiber/fiber/v2 v2.42.0 h1:Fnp7ybWvS+sjNQsFvkhf4G8OhXswvB6Vee8hM/LyS+8=
+github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
+github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
 github.com/gofiber/fiber/v2 v2.42.0/go.mod h1:3+SGNjqMh5VQH5Vz2Wdi43zTIV16ktlFd3x3R6O1Zlc=
+github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
+github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
 github.com/gofiber/jwt/v3 v3.3.6 h1:pXhEQWSAx2fgF50Ej789LY41ujYUZvG13MUJ0o+wO5w=
 github.com/gofiber/jwt/v3 v3.3.6/go.mod h1:jOjegpgD2wUxV32DLTEtBTBP1lal/aFD1oERGpDBqV8=
-github.com/gofiber/template v1.7.1 h1:QCRChZA6UrLROgMbzCMKm4a1yqM/5S8RTBKYWZ9GfL4=
-github.com/gofiber/template v1.7.1/go.mod h1:l3ZOSp8yrMvROzqyh0QTCw7MHet/yLBzaRX+wsiw+gM=
-github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
+github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
+github.com/gofiber/template/html/v2 v2.1.2 h1:wkK/mYJ3nIhongTkG3t0QgV4ADdgOYJYVSAF2AHnh8Y=
+github.com/gofiber/template/html/v2 v2.1.2/go.mod h1:E98Z/FzvpaSib06aWEgYk6GXNf3ctoyaJH8yW5ay5ak=
+github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
+github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
 github.com/gojuno/minimock/v3 v3.3.13 h1:sXFO7RbB4JnZiKhgMO4BU4RLYcfhcOSepfiv4wPgGNY=
 github.com/gojuno/minimock/v3 v3.3.13/go.mod h1:WtJbR+15lbzpUHoOFtT7Sv1rR885bFxoyHrzoMOmK/k=
 github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
@@ -163,7 +103,6 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
 github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
 github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
@@ -171,8 +110,6 @@ github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
 github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
 github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
-github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
-github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -187,10 +124,6 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
 github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -201,17 +134,12 @@ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
 github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -222,74 +150,30 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
 github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
-github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
 github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
-github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
-github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
-github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
-github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
-github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
-github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
-github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
-github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
-github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
-github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
 github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
-github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=
-github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
-github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
-github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
-github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
-github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
-github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
-github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
+github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
-github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
 github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
-github.com/klauspost/compress v1.15.12 h1:YClS/PImqYbn+UILDnqxQCZ3RehC9N318SU3kElDUEM=
-github.com/klauspost/compress v1.15.12/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
-github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
@@ -303,119 +187,57 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR
 github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
 github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
 github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
-github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
-github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
-github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
 github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
-github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
-github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
 github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mattn/go-slim v0.0.0-20200618151855-bde33eecb5ee/go.mod h1:ma9TUJeni8LGZMJvOwbAv/FOwiwqIMQN570LnpqCBSM=
-github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
-github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
-github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
-github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
-github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
-github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
 github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
 github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
-github.com/philhofer/fwd v1.1.1 h1:GdGcTjf5RNAxwS4QLsiMzJYj5KEvPJD3Abr261yRQXQ=
 github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
+github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
+github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
 github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
-github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
-github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
-github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
-github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
 github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
 github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
-github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4=
 github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8=
-github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d h1:Q+gqLBOPkFGHyCJxXMRqtUgUbTjI8/Ze8vu8GGyNFwo=
 github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4=
-github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
-github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
 github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk=
 github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
-github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
 github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
-github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4=
 github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
 github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM=
 github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU=
 github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -423,58 +245,43 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
 github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
-github.com/tinylib/msgp v1.1.6 h1:i+SbKraHhnrf9M5MYmvQhFnbLhAXSDWF8WWsuyRdocw=
 github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw=
-github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
+github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
+github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.40.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I=
-github.com/valyala/fasthttp v1.44.0 h1:R+gLUhldIsfg1HokMuQjdQ5bh9nuXHPIfvkYUu9eR5Q=
 github.com/valyala/fasthttp v1.44.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
+github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
+github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
 github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
 github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
-github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
-go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
-go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
-go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
-go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
-go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
-go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
-go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
-golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
-golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
+golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -498,7 +305,6 @@ golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRu
 golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
 golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
 golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
 golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
 golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
@@ -509,26 +315,20 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
-golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -545,20 +345,13 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
-golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
-golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -568,14 +361,6 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -586,33 +371,22 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -630,51 +404,34 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
+golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -691,7 +448,6 @@ golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgw
 golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -716,7 +472,6 @@ golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
@@ -726,15 +481,10 @@ golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4f
 golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
-golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -758,19 +508,6 @@ google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz513
 google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
 google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
 google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
-google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
-google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
-google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
-google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
-google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
-google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
-google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
-google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
-google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
-google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
-google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -801,7 +538,6 @@ google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfG
 google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
 google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
@@ -814,35 +550,7 @@ google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
-google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
-google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
-google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
-google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
-google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
-google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
-google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -856,21 +564,9 @@ google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3Iji
 google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
 google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
 google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
-google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
-google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
-google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -881,27 +577,14 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
 gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

+ 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
 }

+ 60 - 68
internal/fiber/fiber.go

@@ -1,11 +1,13 @@
 package fiber
 
 import (
+	"errors"
 	"html/template"
-	"log"
 	"strconv"
 	"time"
 
+	cache "git.dmitriygnatenko.ru/dima/go-common/cache/ttl_memory_cache"
+	"git.dmitriygnatenko.ru/dima/go-common/db"
 	"github.com/gofiber/fiber/v2"
 	"github.com/gofiber/fiber/v2/middleware/basicauth"
 	"github.com/gofiber/fiber/v2/middleware/cors"
@@ -13,43 +15,34 @@ import (
 	"github.com/gofiber/fiber/v2/middleware/monitor"
 	"github.com/gofiber/fiber/v2/middleware/recover"
 	jwt "github.com/gofiber/jwt/v3"
-	"github.com/gofiber/template/html"
+	"github.com/gofiber/template/html/v2"
 
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/middleware/language"
 	"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/auth"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/config"
 	"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"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/i18n"
 )
 
 const (
-	appName                     = "dmitriygnatenko"
-	templatesPath               = "./../../internal/templates"
-	staticPath                  = "../../web"
-	metricsURI                  = "/metrics"
-	loginRateLimiterMaxRequests = 10
-	loginRateLimiterExpiration  = 30 * time.Second
+	appName       = "dmitriygnatenko"
+	templatesPath = "./../../internal/templates"
+	staticPath    = "../../web"
+	metricsURI    = "/metrics"
 )
 
-type (
-	ServiceProvider interface {
-		EnvService() *envService.Service
-		MailerService() *mailService.Service
-		AuthService() *authService.Service
-		CacheService() *cacheService.Service
-		ArticleRepository() *repositories.ArticleRepository
-		TagRepository() *repositories.TagRepository
-		ArticleTagRepository() *repositories.ArticleTagRepository
-		UserRepository() *repositories.UserRepository
-	}
-
-	EnvService interface {
-		StaticVersion() int
-		GAKey() string
-	}
-)
+type ServiceProvider interface {
+	ConfigService() *config.Service
+	AuthService() *auth.Service
+	CacheService() *cache.Cache
+	TransactionManager() *db.TxManager
+	ArticleRepository() *repositories.ArticleRepository
+	TagRepository() *repositories.TagRepository
+	ArticleTagRepository() *repositories.ArticleTagRepository
+	UserRepository() *repositories.UserRepository
+}
 
 func Init(sp ServiceProvider) (*fiber.App, error) {
 	fiberApp := fiber.New(getConfig(sp))
@@ -63,13 +56,16 @@ func Init(sp ServiceProvider) (*fiber.App, error) {
 	// Configure recover middleware
 	fiberApp.Use(recover.New())
 
+	// Configure language middleware
+	fiberApp.Use(language.New())
+
 	// Configure JWT auth
 	jwtAuth := jwt.New(getJWTConfig(sp))
 
 	// Configure Basic auth
 	basicAuth := basicauth.New(basicauth.Config{
 		Users: map[string]string{
-			sp.EnvService().BasicAuthUser(): sp.EnvService().BasicAuthPassword(),
+			sp.ConfigService().BasicAuthUser(): sp.ConfigService().BasicAuthPassword(),
 		},
 	})
 
@@ -84,6 +80,7 @@ func Init(sp ServiceProvider) (*fiber.App, error) {
 			sp.ArticleRepository(),
 		),
 	)
+
 	fiberApp.Get(
 		"/tag/:tag", handler.TagHandler(
 			sp.CacheService(),
@@ -91,6 +88,7 @@ func Init(sp ServiceProvider) (*fiber.App, error) {
 			sp.TagRepository(),
 		),
 	)
+
 	fiberApp.Get(
 		"/article/:article",
 		handler.ArticleHandler(
@@ -102,23 +100,24 @@ func Init(sp ServiceProvider) (*fiber.App, error) {
 
 	// Protected handlers
 	admin := fiberApp.Group("/admin", jwtAuth)
+
 	admin.Get(
 		"/",
 		adminHandler.ArticleHandler(sp.ArticleRepository()),
 	)
 
 	admin.All("/login", limiter.New(limiter.Config{
-		Max:        loginRateLimiterMaxRequests,
-		Expiration: loginRateLimiterExpiration,
+		Max:        int(sp.ConfigService().LoginRateLimiterMaxRequests()),
+		Expiration: sp.ConfigService().LoginRateLimiterExpiration(),
 	}), adminHandler.LoginHandler(
-		sp.EnvService(),
+		sp.ConfigService(),
 		sp.AuthService(),
 		sp.UserRepository(),
 	))
 
 	admin.All(
 		"/logout",
-		adminHandler.LogoutHandler(sp.EnvService()),
+		adminHandler.LogoutHandler(sp.ConfigService()),
 	)
 
 	admin.All(
@@ -128,43 +127,50 @@ func Init(sp ServiceProvider) (*fiber.App, error) {
 			sp.UserRepository(),
 		),
 	)
+
 	admin.All(
 		"/article/add",
 		adminHandler.AddArticleHandler(
+			sp.TransactionManager(),
 			sp.ArticleRepository(),
 			sp.TagRepository(),
 			sp.ArticleTagRepository(),
-			sp.CacheService(),
 		),
 	)
+
 	admin.All(
 		"/article/edit/:id<int>",
 		adminHandler.EditArticleHandler(
+			sp.TransactionManager(),
 			sp.ArticleRepository(),
 			sp.TagRepository(),
 			sp.ArticleTagRepository(),
 			sp.CacheService(),
 		),
 	)
+
 	admin.All(
 		"/article/delete/:id<int>",
 		adminHandler.DeleteArticleHandler(
+			sp.TransactionManager(),
 			sp.ArticleRepository(),
 			sp.ArticleTagRepository(),
 			sp.CacheService(),
 		),
 	)
+
 	admin.Get(
 		"/tag",
 		adminHandler.TagHandler(sp.TagRepository()),
 	)
+
 	admin.All(
 		"/tag/add",
 		adminHandler.AddTagHandler(
 			sp.TagRepository(),
-			sp.CacheService(),
 		),
 	)
+
 	admin.All(
 		"/tag/edit/:id<int>",
 		adminHandler.EditTagHandler(
@@ -172,6 +178,7 @@ func Init(sp ServiceProvider) (*fiber.App, error) {
 			sp.CacheService(),
 		),
 	)
+
 	admin.All(
 		"/tag/delete/:id<int>",
 		adminHandler.DeleteTagHandler(
@@ -187,16 +194,16 @@ func getConfig(sp ServiceProvider) fiber.Config {
 	return fiber.Config{
 		AppName:               appName,
 		DisableStartupMessage: true,
-		Views:                 getViewsEngine(sp.EnvService()),
-		ErrorHandler:          getErrorHandler(sp),
+		Views:                 getViewsEngine(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.ConfigService().JWTSecretKey()),
+		TokenLookup: "cookie:" + sp.ConfigService().JWTCookie(),
 		ErrorHandler: func(fctx *fiber.Ctx, err error) error {
 			return fctx.Redirect("/admin/login")
 		},
@@ -226,12 +233,12 @@ func getMetricsConfig() monitor.Config {
 
 func getCORSConfig(sp ServiceProvider) cors.Config {
 	return cors.Config{
-		AllowOrigins: sp.EnvService().CORSAllowOrigins(),
-		AllowMethods: sp.EnvService().CORSAllowMethods(),
+		AllowOrigins: sp.ConfigService().CORSAllowOrigins(),
+		AllowMethods: sp.ConfigService().CORSAllowMethods(),
 	}
 }
 
-func getViewsEngine(env EnvService) *html.Engine {
+func getViewsEngine(sp ServiceProvider) *html.Engine {
 	engine := html.New(templatesPath, ".html")
 
 	// nolint:gocritic
@@ -250,53 +257,38 @@ 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(uint64(sp.ConfigService().StaticVersion()), 10)
 	})
 
 	return engine
 }
 
-func getErrorHandler(sp ServiceProvider) fiber.ErrorHandler {
+func getErrorHandler() fiber.ErrorHandler {
 	return func(fctx *fiber.Ctx, err error) error {
+		lang := i18n.Language(fctx)
 		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 {
 			renderData = fiber.Map{
-				"pageTitle": "Страница не найдена",
+				"pageTitle": i18n.T(lang, "page_not_found_err_title"),
 				"code":      fiber.StatusNotFound,
-				"text":      "Запрашиваемая вами страница не найдена",
+				"text":      i18n.T(lang, "page_not_found_err_desc"),
 			}
 		} else {
 			renderData = fiber.Map{
-				"pageTitle": "Внутренняя ошибка",
+				"pageTitle": i18n.T(lang, "internal_err_title"),
 				"code":      fiber.StatusInternalServerError,
-				"text":      "Внутренняя ошибка сервера, идем исправлять...",
+				"text":      i18n.T(lang, "internal_err_desc"),
 			}
 		}
 
-		renderData["headTitle"] = "От слона к суслику - статьи про PHP, Go, алгоритмы"
+		renderData["headTitle"] = i18n.T(lang, "head_title")
 
 		return fctx.Render("error", renderData, "_layout")
 	}

+ 0 - 54
internal/helpers/datetime.go

@@ -1,54 +0,0 @@
-package helpers
-
-import "time"
-
-const (
-	dateTimeFormat = "2006-01-02"
-	yearFormat     = "2006"
-	monthFormat    = "01"
-	dateFormat     = "02"
-	dayFormat      = "2"
-)
-
-func ParseDateTime(dateTime string) (time.Time, error) {
-	return time.Parse(dateTimeFormat, dateTime)
-}
-
-func FormatDateStr(date time.Time) string {
-	return date.Format(dayFormat) + " " + getMonthStr(date.Format(monthFormat)) + " " + date.Format(yearFormat)
-}
-
-func FormatDateForm(date time.Time) string {
-	return date.Format(yearFormat) + "-" + date.Format(monthFormat) + "-" + date.Format(dateFormat)
-}
-
-func getMonthStr(month string) string {
-	switch month {
-	case "01":
-		return "января"
-	case "02":
-		return "февраля"
-	case "03":
-		return "марта"
-	case "04":
-		return "апреля"
-	case "05":
-		return "мая"
-	case "06":
-		return "июня"
-	case "07":
-		return "июля"
-	case "08":
-		return "августа"
-	case "09":
-		return "сентября"
-	case "10":
-		return "октября"
-	case "11":
-		return "ноября"
-	case "12":
-		return "декабря"
-	default:
-		return ""
-	}
-}

+ 32 - 0
internal/helpers/datetime/datetime.go

@@ -0,0 +1,32 @@
+package datetime
+
+import (
+	"fmt"
+	"time"
+
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/i18n"
+)
+
+const (
+	dateTimeFormat = "2006-01-02"
+	yearFormat     = "2006"
+	monthFormat    = "01"
+	dateFormat     = "02"
+	dayFormat      = "2"
+)
+
+func ParseDateTime(dateTime string) (time.Time, error) {
+	return time.Parse(dateTimeFormat, dateTime)
+}
+
+func FormatDateStr(lang i18n.Lang, date time.Time) string {
+	return date.Format(dayFormat) + " " + getMonthStr(lang, date.Format(monthFormat)) + " " + date.Format(yearFormat)
+}
+
+func FormatDateForm(date time.Time) string {
+	return date.Format(yearFormat) + "-" + date.Format(monthFormat) + "-" + date.Format(dateFormat)
+}
+
+func getMonthStr(lang i18n.Lang, month string) string {
+	return i18n.T(lang, fmt.Sprintf("m%s", month))
+}

+ 1 - 1
internal/helpers/trans.go → internal/helpers/i18n/trans.go

@@ -1,4 +1,4 @@
-package helpers
+package i18n
 
 import (
 	"reflect"

+ 1 - 1
internal/helpers/validate.go → internal/helpers/i18n/validate.go

@@ -1,4 +1,4 @@
-package helpers
+package i18n
 
 import (
 	"unicode"

+ 20 - 0
internal/helpers/request/request.go

@@ -0,0 +1,20 @@
+package request
+
+import (
+	"errors"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+func ConvertToUint64(fctx *fiber.Ctx, key string) (uint64, error) {
+	val, err := fctx.ParamsInt(key)
+	if err != nil {
+		return 0, err
+	}
+
+	if val < 0 {
+		return 0, errors.New("value must be positive")
+	}
+
+	return uint64(val), nil
+}

+ 2 - 6
internal/helpers/fiber.go → internal/helpers/test/fiber.go

@@ -1,4 +1,4 @@
-package helpers
+package test
 
 import (
 	"html/template"
@@ -7,7 +7,7 @@ import (
 
 	"github.com/brianvoe/gofakeit/v6"
 	"github.com/gofiber/fiber/v2"
-	"github.com/gofiber/template/html"
+	"github.com/gofiber/template/html/v2"
 )
 
 func GetFiberTestConfig() fiber.Config {
@@ -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,
 	}

+ 15 - 10
internal/mapper/article.go

@@ -3,13 +3,17 @@ package mapper
 import (
 	"database/sql"
 
+	"github.com/gofiber/fiber/v2"
+
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/dto"
-	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers/datetime"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/i18n"
 )
 
-func ToArticleDTOList(m []models.Article) []dto.Article {
+func ToArticleDTOList(fctx *fiber.Ctx, m []models.Article) []dto.Article {
 	res := make([]dto.Article, 0, len(m))
+	lang := i18n.Language(fctx)
 
 	for i := range m {
 		res = append(res, dto.Article{
@@ -20,14 +24,14 @@ func ToArticleDTOList(m []models.Article) []dto.Article {
 			Image:           m[i].Image.String,
 			MetaKeywords:    m[i].MetaKeywords.String,
 			MetaDescription: m[i].MetaDescription.String,
-			PublishTime:     helpers.FormatDateStr(m[i].PublishTime),
+			PublishTime:     datetime.FormatDateStr(lang, m[i].PublishTime),
 		})
 	}
 
 	return res
 }
 
-func ToArticleDTO(m models.Article) *dto.Article {
+func ToArticleDTO(fctx *fiber.Ctx, m models.Article) *dto.Article {
 	return &dto.Article{
 		ID:              m.ID,
 		URL:             m.URL,
@@ -36,12 +40,13 @@ func ToArticleDTO(m models.Article) *dto.Article {
 		Image:           m.Image.String,
 		MetaKeywords:    m.MetaKeywords.String,
 		MetaDescription: m.MetaDescription.String,
-		PublishTime:     helpers.FormatDateStr(m.PublishTime),
+		PublishTime:     datetime.FormatDateStr(i18n.Language(fctx), m.PublishTime),
 	}
 }
 
-func ToArticlePreviewDTOList(m []models.ArticlePreview) []dto.ArticlePreview {
+func ToArticlePreviewDTOList(fctx *fiber.Ctx, m []models.ArticlePreview) []dto.ArticlePreview {
 	res := make([]dto.ArticlePreview, 0, len(m))
+	lang := i18n.Language(fctx)
 
 	for i := range m {
 		res = append(res, dto.ArticlePreview{
@@ -50,7 +55,7 @@ func ToArticlePreviewDTOList(m []models.ArticlePreview) []dto.ArticlePreview {
 			Title:       m[i].Title,
 			PreviewText: m[i].PreviewText.String,
 			Image:       m[i].Image.String,
-			PublishTime: helpers.FormatDateStr(m[i].PublishTime),
+			PublishTime: datetime.FormatDateStr(lang, m[i].PublishTime),
 		})
 	}
 
@@ -58,7 +63,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
 	}
@@ -73,7 +78,7 @@ func ToArticleForm(a models.Article, tags []models.Tag) *models.ArticleForm {
 		PreviewText:     a.PreviewText.String,
 		MetaKeywords:    a.MetaKeywords.String,
 		MetaDescription: a.MetaDescription.String,
-		PublishTime:     helpers.FormatDateForm(a.PublishTime),
+		PublishTime:     datetime.FormatDateForm(a.PublishTime),
 		ActiveTags:      tagMap,
 	}
 }
@@ -109,7 +114,7 @@ func ToArticle(f models.ArticleForm) (*models.Article, error) {
 		}
 	}
 
-	parsedDateTime, err := helpers.ParseDateTime(f.PublishTime)
+	parsedDateTime, err := datetime.ParseDateTime(f.PublishTime)
 	if err != nil {
 		return nil, err
 	}

+ 15 - 0
internal/middleware/language/language.go

@@ -0,0 +1,15 @@
+package language
+
+import (
+	"github.com/gofiber/fiber/v2"
+
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/i18n"
+)
+
+func New() fiber.Handler {
+	return func(c *fiber.Ctx) (err error) {
+		c.Locals(i18n.CtxLanguageKey, i18n.Ru) // now only RU language
+
+		return c.Next()
+	}
+}

+ 18 - 18
internal/models/article.go

@@ -6,29 +6,29 @@ import (
 )
 
 type ArticlePreview struct {
-	ID          int
-	URL         string
-	Title       string
-	PublishTime time.Time
-	PreviewText sql.NullString
-	Image       sql.NullString
+	ID          uint64         `db:"id"`
+	URL         string         `db:"url"`
+	Title       string         `db:"title"`
+	PublishTime time.Time      `db:"publish_time"`
+	PreviewText sql.NullString `db:"preview_text"`
+	Image       sql.NullString `db:"image"`
 }
 
 type Article struct {
-	ID              int
-	URL             string
-	Title           string
-	PublishTime     time.Time
-	Text            string
-	PreviewText     sql.NullString
-	IsActive        bool
-	Image           sql.NullString
-	MetaKeywords    sql.NullString
-	MetaDescription sql.NullString
+	ID              uint64         `db:"id"`
+	URL             string         `db:"url"`
+	Title           string         `db:"title"`
+	PublishTime     time.Time      `db:"publish_time"`
+	Text            string         `db:"text"`
+	PreviewText     sql.NullString `db:"preview_text"`
+	IsActive        bool           `db:"is_active"`
+	Image           sql.NullString `db:"image"`
+	MetaKeywords    sql.NullString `db:"meta_keywords"`
+	MetaDescription sql.NullString `db:"meta_desc"`
 }
 
 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
 }

+ 4 - 4
internal/models/tag.go

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

+ 5 - 5
internal/models/user.go

@@ -5,11 +5,11 @@ import (
 )
 
 type User struct {
-	ID        int
-	Username  string
-	Password  string
-	CreatedAt time.Time
-	UpdatedAt time.Time
+	ID        uint64    `db:"id"`
+	Username  string    `db:"username"`
+	Password  string    `db:"password"`
+	CreatedAt time.Time `db:"created_at"`
+	UpdatedAt time.Time `db:"updated_at"`
 }
 
 type ChangePasswordForm struct {

+ 80 - 91
internal/repositories/article.go

@@ -2,7 +2,7 @@ package repositories
 
 import (
 	"context"
-	"database/sql"
+	"fmt"
 
 	sq "github.com/Masterminds/squirrel"
 
@@ -11,18 +11,31 @@ 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
 }
 
-func InitArticleRepository(db *sql.DB) *ArticleRepository {
+func InitArticleRepository(db DB) *ArticleRepository {
 	return &ArticleRepository{db: db}
 }
 
 func (a ArticleRepository) GetAllPreview(ctx context.Context) ([]models.ArticlePreview, error) {
 	var res []models.ArticlePreview
 
-	query, args, err := sq.Select("id", "url", "publish_time", "title", "preview_text", "image").
+	q, v, err := sq.Select("id", "url", "publish_time", "title", "preview_text", "image").
 		From(articleTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"is_active": true}).
@@ -30,28 +43,12 @@ func (a ArticleRepository) GetAllPreview(ctx context.Context) ([]models.ArticleP
 		ToSql()
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("build query: %w", err)
 	}
 
-	rows, err := a.db.QueryContext(ctx, query, args...)
+	err = a.db.SelectContext(ctx, &res, q, v...)
 	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-
-	for rows.Next() {
-		row := models.ArticlePreview{}
-
-		err = rows.Scan(&row.ID, &row.URL, &row.PublishTime, &row.Title, &row.PreviewText, &row.Image)
-		if err != nil {
-			return nil, err
-		}
-
-		res = append(res, row)
-	}
-
-	if err = rows.Err(); err != nil {
-		return nil, err
+		return nil, fmt.Errorf("select: %w", err)
 	}
 
 	return res, nil
@@ -60,63 +57,34 @@ 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").
+	q, v, err := sq.Select(articleTableColumns...).
 		From(articleTableName).
 		OrderBy("publish_time DESC").
 		ToSql()
 
-	rows, err := a.db.QueryContext(ctx, query, args...)
 	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-
-	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)
-		if err != nil {
-			return nil, err
-		}
-
-		res = append(res, row)
+		return nil, fmt.Errorf("build query: %w", err)
 	}
 
-	if err = rows.Err(); err != nil {
-		return nil, err
+	err = a.db.SelectContext(ctx, &res, q, v...)
+	if err != nil {
+		return nil, fmt.Errorf("select: %w", err)
 	}
 
 	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 " +
+	q := "SELECT a.id, a.url, a.publish_time, a.title, a.preview_text, a.image " +
 		"FROM " + articleTableName + " a, " + articleTagTableName + " at " +
 		"WHERE a.is_active = true AND at.article_id = a.id AND at.tag_id = $1 " +
 		"ORDER BY a.publish_time DESC"
 
-	rows, err := a.db.QueryContext(ctx, query, tagID)
+	err := a.db.SelectContext(ctx, &res, q, tagID)
 	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-
-	for rows.Next() {
-		row := models.ArticlePreview{}
-
-		err = rows.Scan(&row.ID, &row.URL, &row.PublishTime, &row.Title, &row.PreviewText, &row.Image)
-		if err != nil {
-			return nil, err
-		}
-
-		res = append(res, row)
-	}
-
-	if err = rows.Err(); err != nil {
-		return nil, err
+		return nil, fmt.Errorf("select: %w", err)
 	}
 
 	return res, nil
@@ -125,7 +93,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").
+	q, v, err := sq.Select(articleTableColumns...).
 		From(articleTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"url": url}).
@@ -133,23 +101,21 @@ func (a ArticleRepository) GetByURL(ctx context.Context, url string) (*models.Ar
 		ToSql()
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("build query: %w", err)
 	}
 
-	err = a.db.QueryRowContext(ctx, query, args...).
-		Scan(&res.ID, &res.URL, &res.PublishTime, &res.Title, &res.Image, &res.Text, &res.PreviewText, &res.MetaKeywords, &res.MetaDescription, &res.IsActive)
-
+	err = a.db.GetContext(ctx, &res, q, v...)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("get: %w", err)
 	}
 
 	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").
+	q, v, err := sq.Select(articleTableColumns...).
 		From(articleTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"id": id}).
@@ -157,42 +123,59 @@ func (a ArticleRepository) GetByID(ctx context.Context, id int) (*models.Article
 		ToSql()
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("build query: %w", err)
 	}
 
-	err = a.db.QueryRowContext(ctx, query, args...).
-		Scan(&res.ID, &res.URL, &res.PublishTime, &res.Title, &res.Image, &res.Text, &res.PreviewText, &res.MetaKeywords, &res.MetaDescription, &res.IsActive)
-
+	err = a.db.GetContext(ctx, &res, q, v...)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("get: %w", err)
 	}
 
 	return &res, nil
 }
 
-func (a ArticleRepository) Add(ctx context.Context, m models.Article) (int, error) {
-	query, args, err := sq.Insert(articleTableName).
+func (a ArticleRepository) Add(ctx context.Context, m models.Article) (uint64, error) {
+	q, v, 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()
 
 	if err != nil {
-		return 0, err
+		return 0, fmt.Errorf("build query: %w", err)
 	}
 
-	var id int
-	err = a.db.QueryRowContext(ctx, query, args...).Scan(&id)
+	var id uint64
+	err = a.db.QueryRowContext(ctx, q, v...).Scan(&id)
 	if err != nil {
-		return 0, err
+		return 0, fmt.Errorf("query row: %w", err)
 	}
 
 	return id, nil
 }
 
 func (a ArticleRepository) Update(ctx context.Context, req models.Article) error {
-	query, args, err := sq.Update(articleTableName).
+	q, v, err := sq.Update(articleTableName).
 		PlaceholderFormat(sq.Dollar).
 		Set("url", req.URL).
 		Set("publish_time", req.PublishTime).
@@ -207,25 +190,31 @@ func (a ArticleRepository) Update(ctx context.Context, req models.Article) error
 		ToSql()
 
 	if err != nil {
-		return err
+		return fmt.Errorf("build query: %w", err)
 	}
 
-	_, err = a.db.ExecContext(ctx, query, args...)
+	_, err = a.db.ExecContext(ctx, q, v...)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
 
-	return err
+	return nil
 }
 
-func (a ArticleRepository) Delete(ctx context.Context, id int) error {
-	query, args, err := sq.Delete(articleTableName).
+func (a ArticleRepository) Delete(ctx context.Context, id uint64) error {
+	q, v, err := sq.Delete(articleTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"id": id}).
 		ToSql()
 
 	if err != nil {
-		return err
+		return fmt.Errorf("build query: %w", err)
 	}
 
-	_, err = a.db.ExecContext(ctx, query, args...)
+	_, err = a.db.ExecContext(ctx, q, v...)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
 
-	return err
+	return nil
 }

+ 27 - 18
internal/repositories/article_tag.go

@@ -2,7 +2,7 @@ package repositories
 
 import (
 	"context"
-	"database/sql"
+	"fmt"
 
 	sq "github.com/Masterminds/squirrel"
 )
@@ -10,14 +10,14 @@ import (
 const articleTagTableName = "article_tag"
 
 type ArticleTagRepository struct {
-	db *sql.DB
+	db DB
 }
 
-func InitArticleTagRepository(db *sql.DB) *ArticleTagRepository {
+func InitArticleTagRepository(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
 	}
@@ -30,47 +30,56 @@ func (a ArticleTagRepository) Add(ctx context.Context, articleID int, tagIDs []i
 		qb = qb.Values(articleID, tagID)
 	}
 
-	query, args, err := qb.ToSql()
+	q, v, err := qb.ToSql()
 	if err != nil {
-		return err
+		return fmt.Errorf("build query: %w", err)
 	}
 
-	_, err = a.db.ExecContext(ctx, query, args...)
+	_, err = a.db.ExecContext(ctx, q, v...)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
 
-	return err
+	return nil
 }
 
-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
 	}
 
-	query, args, err := sq.Delete(articleTagTableName).
+	q, v, err := sq.Delete(articleTagTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"article_id": articleID}).
 		Where(sq.Eq{"tag_id": tagIDs}).
 		ToSql()
 
 	if err != nil {
-		return err
+		return fmt.Errorf("build query: %w", err)
 	}
 
-	_, err = a.db.ExecContext(ctx, query, args...)
+	_, err = a.db.ExecContext(ctx, q, v...)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
 
-	return err
+	return nil
 }
 
-func (a ArticleTagRepository) DeleteByArticleID(ctx context.Context, articleID int) error {
-	query, args, err := sq.Delete(articleTagTableName).
+func (a ArticleTagRepository) DeleteByArticleID(ctx context.Context, articleID uint64) error {
+	q, v, err := sq.Delete(articleTagTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"article_id": articleID}).
 		ToSql()
 
 	if err != nil {
-		return err
+		return fmt.Errorf("build query: %w", err)
 	}
 
-	_, err = a.db.ExecContext(ctx, query, args...)
+	_, err = a.db.ExecContext(ctx, q, v...)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
 
-	return err
+	return nil
 }

+ 39 - 0
internal/repositories/common.go

@@ -0,0 +1,39 @@
+package repositories
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+
+	"github.com/lib/pq"
+)
+
+const (
+	FKViolationErrorCode  = "23503"
+	DuplicateKeyErrorCode = "23505"
+)
+
+type DB interface {
+	SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error
+	GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error
+	ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
+	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
+}
+
+func IsFKViolationError(err error) bool {
+	var pgErr *pq.Error
+	if errors.As(err, &pgErr) {
+		return pgErr.Code == FKViolationErrorCode
+	}
+
+	return false
+}
+
+func IsDuplicateKeyError(err error) bool {
+	var pgErr *pq.Error
+	if errors.As(err, &pgErr) {
+		return pgErr.Code == DuplicateKeyErrorCode
+	}
+
+	return false
+}

+ 51 - 94
internal/repositories/tag.go

@@ -2,7 +2,7 @@ package repositories
 
 import (
 	"context"
-	"database/sql"
+	"fmt"
 
 	sq "github.com/Masterminds/squirrel"
 
@@ -12,48 +12,34 @@ import (
 const tagTableName = "tag"
 
 type TagRepository struct {
-	db *sql.DB
+	db DB
 }
 
-func InitTagRepository(db *sql.DB) *TagRepository {
+func InitTagRepository(db DB) *TagRepository {
 	return &TagRepository{db: db}
 }
 
 func (t TagRepository) GetAllUsed(ctx context.Context) ([]models.Tag, error) {
 	var res []models.Tag
 
-	query := "SELECT t.id, t.url, t.tag " +
+	q := "SELECT t.id, t.url, t.tag " +
 		"FROM " + articleTagTableName + " at, " + tagTableName + " t " +
 		"WHERE t.id = at.tag_id AND at.article_id IN " +
 		"(SELECT id FROM " + articleTableName + " " + "WHERE is_active = true) " +
 		"GROUP BY t.id"
 
-	rows, err := t.db.QueryContext(ctx, query)
+	err := t.db.SelectContext(ctx, &res, q)
 	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-
-	for rows.Next() {
-		row := models.Tag{}
-
-		err = rows.Scan(&row.ID, &row.URL, &row.Tag)
-		if err != nil {
-			return nil, err
-		}
-
-		res = append(res, row)
-	}
-
-	if err = rows.Err(); err != nil {
-		return nil, err
+		return nil, fmt.Errorf("select: %w", err)
 	}
 
 	return res, nil
 }
 
 func (t TagRepository) GetByURL(ctx context.Context, url string) (*models.Tag, error) {
-	query, args, err := sq.Select("id", "url", "tag").
+	var res models.Tag
+
+	q, v, err := sq.Select("id", "url", "tag").
 		From(tagTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"url": url}).
@@ -61,23 +47,21 @@ func (t TagRepository) GetByURL(ctx context.Context, url string) (*models.Tag, e
 		ToSql()
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("build query: %w", err)
 	}
 
-	var res models.Tag
-
-	err = t.db.QueryRowContext(ctx, query, args...).
-		Scan(&res.ID, &res.URL, &res.Tag)
-
+	err = t.db.GetContext(ctx, &res, q, v...)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("get: %w", err)
 	}
 
 	return &res, nil
 }
 
-func (t TagRepository) GetByID(ctx context.Context, id int) (*models.Tag, error) {
-	query, args, err := sq.Select("id", "url", "tag").
+func (t TagRepository) GetByID(ctx context.Context, id uint64) (*models.Tag, error) {
+	var res models.Tag
+
+	q, v, err := sq.Select("id", "url", "tag").
 		From(tagTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"id": id}).
@@ -85,16 +69,12 @@ func (t TagRepository) GetByID(ctx context.Context, id int) (*models.Tag, error)
 		ToSql()
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("build query: %w", err)
 	}
 
-	var res models.Tag
-
-	err = t.db.QueryRowContext(ctx, query, args...).
-		Scan(&res.ID, &res.URL, &res.Tag)
-
+	err = t.db.GetContext(ctx, &res, q, v...)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("get: %w", err)
 	}
 
 	return &res, nil
@@ -103,70 +83,38 @@ func (t TagRepository) GetByID(ctx context.Context, id int) (*models.Tag, error)
 func (t TagRepository) GetAll(ctx context.Context) ([]models.Tag, error) {
 	var res []models.Tag
 
-	query, args, err := sq.Select("id", "url", "tag").
+	q, v, err := sq.Select("id", "url", "tag").
 		From(tagTableName).
 		ToSql()
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("build query: %w", err)
 	}
 
-	rows, err := t.db.QueryContext(ctx, query, args...)
+	err = t.db.SelectContext(ctx, &res, q, v...)
 	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-
-	for rows.Next() {
-		row := models.Tag{}
-
-		err = rows.Scan(&row.ID, &row.URL, &row.Tag)
-		if err != nil {
-			return nil, err
-		}
-
-		res = append(res, row)
-	}
-
-	if err = rows.Err(); err != nil {
-		return nil, err
+		return nil, fmt.Errorf("select: %w", err)
 	}
 
 	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 " +
+	q := "SELECT t.id, t.url, t.tag " +
 		"FROM " + articleTagTableName + " at, " + tagTableName + " t " +
 		"WHERE t.id = at.tag_id AND at.article_id = $1"
 
-	rows, err := t.db.QueryContext(ctx, query, id)
+	err := t.db.SelectContext(ctx, &res, q, id)
 	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-
-	for rows.Next() {
-		row := models.Tag{}
-
-		err = rows.Scan(&row.ID, &row.URL, &row.Tag)
-		if err != nil {
-			return nil, err
-		}
-
-		res = append(res, row)
-	}
-
-	if err = rows.Err(); err != nil {
-		return nil, err
+		return nil, fmt.Errorf("select: %w", err)
 	}
 
 	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).
@@ -174,7 +122,7 @@ func (t TagRepository) IsUsed(ctx context.Context, id int) (bool, error) {
 		ToSql()
 
 	if err != nil {
-		return false, err
+		return false, fmt.Errorf("build query: %w", err)
 	}
 
 	var count int
@@ -187,23 +135,26 @@ func (t TagRepository) IsUsed(ctx context.Context, id int) (bool, error) {
 }
 
 func (t TagRepository) Add(ctx context.Context, req models.Tag) error {
-	query, args, err := sq.Insert(tagTableName).
+	q, v, err := sq.Insert(tagTableName).
 		PlaceholderFormat(sq.Dollar).
 		Columns("tag", "url").
 		Values(req.Tag, req.URL).
 		ToSql()
 
 	if err != nil {
-		return err
+		return fmt.Errorf("build query: %w", err)
 	}
 
-	_, err = t.db.ExecContext(ctx, query, args...)
+	_, err = t.db.ExecContext(ctx, q, v...)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
 
-	return err
+	return nil
 }
 
 func (t TagRepository) Update(ctx context.Context, req models.Tag) error {
-	query, args, err := sq.Update(tagTableName).
+	q, v, err := sq.Update(tagTableName).
 		PlaceholderFormat(sq.Dollar).
 		Set("tag", req.Tag).
 		Set("url", req.URL).
@@ -211,25 +162,31 @@ func (t TagRepository) Update(ctx context.Context, req models.Tag) error {
 		ToSql()
 
 	if err != nil {
-		return err
+		return fmt.Errorf("build query: %w", err)
 	}
 
-	_, err = t.db.ExecContext(ctx, query, args...)
+	_, err = t.db.ExecContext(ctx, q, v...)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
 
-	return err
+	return nil
 }
 
-func (t TagRepository) Delete(ctx context.Context, id int) error {
-	query, args, err := sq.Delete(tagTableName).
+func (t TagRepository) Delete(ctx context.Context, id uint64) error {
+	q, v, err := sq.Delete(tagTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"id": id}).
 		ToSql()
 
 	if err != nil {
-		return err
+		return fmt.Errorf("build query: %w", err)
 	}
 
-	_, err = t.db.ExecContext(ctx, query, args...)
+	_, err = t.db.ExecContext(ctx, q, v...)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
 
-	return err
+	return nil
 }

+ 23 - 22
internal/repositories/user.go

@@ -2,7 +2,7 @@ package repositories
 
 import (
 	"context"
-	"database/sql"
+	"fmt"
 
 	sq "github.com/Masterminds/squirrel"
 
@@ -14,37 +14,36 @@ const (
 )
 
 type UserRepository struct {
-	db *sql.DB
+	db DB
 }
 
-func InitUserRepository(db *sql.DB) *UserRepository {
+func InitUserRepository(db DB) *UserRepository {
 	return &UserRepository{db: db}
 }
 
 func (u UserRepository) Get(ctx context.Context, username string) (*models.User, error) {
-	query, args, err := sq.Select("id", "username", "password", "created_at", "updated_at").
+	var res models.User
+
+	q, v, err := sq.Select("id", "username", "password", "created_at", "updated_at").
 		From(userTableName).
 		PlaceholderFormat(sq.Dollar).
 		Where(sq.Eq{"username": username}).
 		ToSql()
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("build query: %w", err)
 	}
 
-	var res models.User
-	err = u.db.QueryRowContext(ctx, query, args...).
-		Scan(&res.ID, &res.Username, &res.Password, &res.CreatedAt, &res.UpdatedAt)
-
+	err = u.db.GetContext(ctx, &res, q, v...)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("get: %w", err)
 	}
 
 	return &res, nil
 }
 
-func (u UserRepository) Add(ctx context.Context, username string, password string) (int, error) {
-	query, args, err := sq.Insert(userTableName).
+func (u UserRepository) Add(ctx context.Context, username string, password string) (uint64, error) {
+	q, v, err := sq.Insert(userTableName).
 		PlaceholderFormat(sq.Dollar).
 		Columns("username", "password").
 		Values(username, password).
@@ -52,19 +51,19 @@ func (u UserRepository) Add(ctx context.Context, username string, password strin
 		ToSql()
 
 	if err != nil {
-		return 0, err
+		return 0, fmt.Errorf("build query: %w", err)
 	}
 
-	var id int
-	if err = u.db.QueryRowContext(ctx, query, args...).Scan(&id); err != nil {
-		return 0, err
+	var id uint64
+	if err = u.db.QueryRowContext(ctx, q, v...).Scan(&id); err != nil {
+		return 0, fmt.Errorf("query row: %w", err)
 	}
 
 	return id, nil
 }
 
-func (u UserRepository) UpdatePassword(ctx context.Context, id int, newPassword string) error {
-	query, args, err := sq.Update(userTableName).
+func (u UserRepository) UpdatePassword(ctx context.Context, id uint64, newPassword string) error {
+	q, v, err := sq.Update(userTableName).
 		PlaceholderFormat(sq.Dollar).
 		Set("password", newPassword).
 		Set("updated_at", "NOW()").
@@ -72,11 +71,13 @@ func (u UserRepository) UpdatePassword(ctx context.Context, id int, newPassword
 		ToSql()
 
 	if err != nil {
-		return err
+		return fmt.Errorf("build query: %w", err)
 	}
 
-	_, err = u.db.ExecContext(ctx, query, args...)
-
-	return err
+	_, err = u.db.ExecContext(ctx, q, v...)
+	if err != nil {
+		return fmt.Errorf("exec: %w", err)
+	}
 
+	return nil
 }

+ 90 - 32
internal/service_provider/sp.go

@@ -1,19 +1,22 @@
 package sp
 
 import (
+	cache "git.dmitriygnatenko.ru/dima/go-common/cache/ttl_memory_cache"
+	"git.dmitriygnatenko.ru/dima/go-common/db"
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
+	"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"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/auth"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/config"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/i18n"
 )
 
 type ServiceProvider struct {
-	env                  *envService.Service
-	cache                *cacheService.Service
-	mailer               *mailService.Service
-	auth                 *authService.Service
+	config               *config.Service
+	auth                 *auth.Service
+	cache                *cache.Cache
+	tm                   *db.TxManager
 	articleRepository    *repositories.ArticleRepository
 	tagRepository        *repositories.TagRepository
 	articleTagRepository *repositories.ArticleTagRepository
@@ -24,58 +27,113 @@ func Init() (*ServiceProvider, error) {
 	sp := &ServiceProvider{}
 
 	// Init services
-	env, err := envService.Init()
+	configService, err := config.Init()
 	if err != nil {
 		return nil, err
 	}
-	sp.env = env
+	sp.config = configService
+
+	sp.cache = cache.NewCache(
+		cache.NewConfig(
+			cache.WithExpiration(configService.CacheDefaultDuration()),
+			cache.WithCleanupInterval(configService.CacheCleanupInterval()),
+		),
+	)
 
-	cache, err := cacheService.Init()
+	authService, err := auth.Init(sp.config)
 	if err != nil {
 		return nil, err
 	}
-	sp.cache = cache
+	sp.auth = authService
+
+	dbService, err := db.NewDB(
+		db.NewConfig(
+			db.WithDriver(configService.DBDriver()),
+			db.WithHost(configService.DBHost()),
+			db.WithPort(configService.DBPort()),
+			db.WithUsername(configService.DBUser()),
+			db.WithPassword(configService.DBPassword()),
+			db.WithDatabase(configService.DBName()),
+			db.WithMaxIdleConns(configService.DBMaxIdleConns()),
+			db.WithMaxOpenConns(configService.DBMaxOpenConns()),
+			db.WithMaxIdleConnLifetime(configService.DBMaxOpenConnLifetime()),
+			db.WithMaxOpenConnLifetime(configService.DBMaxOpenConnLifetime()),
+			db.WithSSLMode(configService.DBSSLMode()),
+		),
+	)
 
-	mailer, err := mailService.Init(sp.env)
 	if err != nil {
 		return nil, err
 	}
-	sp.mailer = mailer
 
-	auth, err := authService.Init(sp.env)
-	if err != nil {
-		return nil, err
+	// Init transaction manager
+	sp.tm = db.NewTransactionManager(dbService)
+
+	// Init repositories
+	sp.articleRepository = repositories.InitArticleRepository(dbService)
+	sp.tagRepository = repositories.InitTagRepository(dbService)
+	sp.articleTagRepository = repositories.InitArticleTagRepository(dbService)
+	sp.userRepository = repositories.InitUserRepository(dbService)
+
+	// Init logger
+	loggerConfigOptions := logger.ConfigOptions{
+		logger.WithEmailLogEnabled(configService.LoggerEmailEnabled()),
+		logger.WithEmailLogLevel(configService.LoggerEmailLevel()),
+		logger.WithEmailSubject(configService.LoggerEmailSubject()),
+		logger.WithEmailRecipient(configService.LoggerEmailRecipient()),
+		logger.WithEmailLogAddSource(configService.LoggerEmailAddSource()),
+		logger.WithStdoutLogEnabled(configService.LoggerStdoutEnabled()),
+		logger.WithStdoutLogLevel(configService.LoggerStdoutLevel()),
+		logger.WithStdoutLogAddSource(configService.LoggerStdoutAddSource()),
 	}
-	sp.auth = auth
 
-	db, err := dbService.Init(env)
+	if len(configService.SMTPPassword()) > 0 &&
+		len(configService.SMTPUser()) > 0 &&
+		len(configService.SMTPHost()) > 0 &&
+		configService.SMTPPort() > 0 {
+
+		smtpService, smtpErr := smtp.NewSMTP(
+			smtp.NewConfig(
+				smtp.WithPassword(configService.SMTPPassword()),
+				smtp.WithUsername(configService.SMTPUser()),
+				smtp.WithHost(configService.SMTPHost()),
+				smtp.WithPort(configService.SMTPPort()),
+			),
+		)
+		if smtpErr != nil {
+			return nil, smtpErr
+		}
+
+		loggerConfigOptions.Add(logger.WithSMTPClient(smtpService))
+	}
+
+	err = logger.Init(logger.NewConfig(loggerConfigOptions...))
 	if err != nil {
 		return nil, err
 	}
 
-	// Init repositories
-	sp.articleRepository = repositories.InitArticleRepository(db)
-	sp.tagRepository = repositories.InitTagRepository(db)
-	sp.articleTagRepository = repositories.InitArticleTagRepository(db)
-	sp.userRepository = repositories.InitUserRepository(db)
+	// Init translations
+	if err = i18n.Init(); err != nil {
+		return nil, err
+	}
 
 	return sp, nil
 }
 
-func (sp *ServiceProvider) EnvService() *envService.Service {
-	return sp.env
+func (sp *ServiceProvider) ConfigService() *config.Service {
+	return sp.config
 }
 
-func (sp *ServiceProvider) CacheService() *cacheService.Service {
+func (sp *ServiceProvider) CacheService() *cache.Cache {
 	return sp.cache
 }
 
-func (sp *ServiceProvider) MailerService() *mailService.Service {
-	return sp.mailer
+func (sp *ServiceProvider) AuthService() *auth.Service {
+	return sp.auth
 }
 
-func (sp *ServiceProvider) AuthService() *authService.Service {
-	return sp.auth
+func (sp *ServiceProvider) TransactionManager() *db.TxManager {
+	return sp.tm
 }
 
 func (sp *ServiceProvider) ArticleRepository() *repositories.ArticleRepository {

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

@@ -17,17 +17,17 @@ const (
 	defaultCost  = bcrypt.DefaultCost
 )
 
-type Env interface {
+type Config interface {
 	JWTSecretKey() string
-	JWTLifetime() int
+	JWTLifeTime() time.Duration
 }
 
 type Service struct {
-	env Env
+	config Config
 }
 
-func Init(env Env) (*Service, error) {
-	return &Service{env: env}, nil
+func Init(c Config) (*Service, error) {
+	return &Service{config: c}, nil
 }
 
 func (a Service) GeneratePasswordHash(password string) (string, error) {
@@ -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.config.JWTLifeTime()).Unix(),
 	}
 
 	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
 
-	return token.SignedString([]byte(a.env.JWTSecretKey()))
+	return token.SignedString([]byte(a.config.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{})
-}

+ 293 - 0
internal/services/config/config.go

@@ -0,0 +1,293 @@
+package config
+
+import (
+	"flag"
+	"time"
+
+	"github.com/spf13/viper"
+)
+
+const defaultConfigPath = "./.env"
+
+type Service struct {
+	appPort                     uint16
+	corsAllowOrigins            string
+	corsAllowMethods            string
+	dbDriver                    string
+	dbHost                      string
+	dbPort                      uint16
+	dbName                      string
+	dbUser                      string
+	dbPassword                  string
+	dbSSLMode                   string
+	dbMaxOpenConns              uint16
+	dbMaxIdleConns              uint16
+	dbMaxOpenConnLifetime       time.Duration
+	dbMaxIdleConnLifetime       time.Duration
+	cacheDefaultDuration        time.Duration
+	cacheCleanupInterval        time.Duration
+	smtpHost                    string
+	smtpPort                    uint16
+	smtpUser                    string
+	smtpPassword                string
+	jwtSecretKey                string
+	jwtLifeTime                 time.Duration
+	jwtCookie                   string
+	basicAuthUser               string
+	basicAuthPassword           string
+	staticVersion               uint16
+	loggerStdoutEnabled         bool
+	loggerStdoutLevel           string
+	loggerStdoutAddSource       bool
+	loggerEmailEnabled          bool
+	loggerEmailLevel            string
+	loggerEmailAddSource        bool
+	loggerEmailSubject          string
+	loggerEmailRecipient        string
+	loginRateLimiterMaxRequests uint16
+	loginRateLimiterExpiration  time.Duration
+}
+
+func Init() (*Service, error) {
+	var configPath string
+	flag.StringVar(&configPath, "config", "", "Path to .env config file")
+	flag.Parse()
+
+	if configPath == "" {
+		configPath = defaultConfigPath
+	}
+
+	viper.SetConfigFile(configPath)
+	viper.SetConfigType("env")
+	viper.AutomaticEnv()
+
+	if err := viper.ReadInConfig(); err != nil {
+		return nil, err
+	}
+
+	s := struct {
+		AppPort                     uint16        `mapstructure:"APP_PORT"`
+		CorsAllowOriginsOrigins     string        `mapstructure:"CORS_ALLOW_ORIGIN"`
+		CorsAllowMethods            string        `mapstructure:"CORS_ALLOW_METHODS"`
+		DBDriver                    string        `mapstructure:"DB_DRIVER"`
+		DBHost                      string        `mapstructure:"DB_HOST"`
+		DBPort                      uint16        `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                    uint16        `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               uint16        `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"`
+		LoggerEmailRecipient        string        `mapstructure:"LOGGER_EMAIL_RECIPIENT"`
+		LoginRateLimiterMaxRequests uint16        `mapstructure:"LOGIN_RATE_LIMITER_MAX_REQUESTS"`
+		LoginRateLimiterExpiration  time.Duration `mapstructure:"LOGIN_RATE_LIMITER_EXPIRATION"`
+	}{}
+
+	if err := viper.Unmarshal(&s); err != nil {
+		return nil, err
+	}
+
+	return &Service{
+		appPort:                     s.AppPort,
+		corsAllowOrigins:            s.CorsAllowOriginsOrigins,
+		corsAllowMethods:            s.CorsAllowMethods,
+		dbDriver:                    s.DBDriver,
+		dbHost:                      s.DBHost,
+		dbPort:                      s.DBPort,
+		dbName:                      s.DBName,
+		dbUser:                      s.DBUser,
+		dbPassword:                  s.DBPassword,
+		dbSSLMode:                   s.DBSSLMode,
+		dbMaxOpenConns:              s.DBMaxOpenConns,
+		dbMaxIdleConns:              s.DBMaxIdleConns,
+		dbMaxOpenConnLifetime:       s.DBMaxOpenConnLifetime,
+		dbMaxIdleConnLifetime:       s.DBMaxIdleConnLifetime,
+		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,
+		staticVersion:               s.StaticVersion,
+		loggerStdoutEnabled:         s.LoggerStdoutEnabled,
+		loggerStdoutLevel:           s.LoggerStdoutLevel,
+		loggerStdoutAddSource:       s.LoggerStdoutAddSource,
+		loggerEmailEnabled:          s.LoggerEmailEnabled,
+		loggerEmailLevel:            s.LoggerEmailLevel,
+		loggerEmailAddSource:        s.LoggerEmailAddSource,
+		loggerEmailSubject:          s.LoggerEmailSubject,
+		loggerEmailRecipient:        s.LoggerEmailRecipient,
+		loginRateLimiterMaxRequests: s.LoginRateLimiterMaxRequests,
+		loginRateLimiterExpiration:  s.LoginRateLimiterExpiration,
+	}, nil
+}
+
+func (s Service) AppPort() uint16 {
+	return s.appPort
+}
+
+func (s Service) CORSAllowOrigins() string {
+	return s.corsAllowOrigins
+}
+
+func (s Service) CORSAllowMethods() string {
+	return s.corsAllowMethods
+}
+
+func (s Service) DBDriver() string {
+	return s.dbDriver
+}
+
+func (s Service) DBHost() string {
+	return s.dbHost
+}
+
+func (s Service) DBPort() uint16 {
+	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 (s Service) DBMaxIdleConns() uint16 {
+	return s.dbMaxIdleConns
+}
+
+func (s Service) DBMaxOpenConnLifetime() time.Duration {
+	return s.dbMaxOpenConnLifetime
+}
+
+func (s Service) DBMaxIdleConnLifetime() time.Duration {
+	return s.dbMaxIdleConnLifetime
+}
+
+func (s Service) CacheDefaultDuration() time.Duration {
+	return s.cacheDefaultDuration
+}
+
+func (s Service) CacheCleanupInterval() time.Duration {
+	return s.cacheCleanupInterval
+}
+
+func (s Service) SMTPHost() string {
+	return s.smtpHost
+}
+
+func (s Service) SMTPPort() uint16 {
+	return s.smtpPort
+}
+
+func (s Service) SMTPUser() string {
+	return s.smtpUser
+}
+
+func (s Service) SMTPPassword() string {
+	return s.smtpPassword
+}
+
+func (s Service) JWTSecretKey() string {
+	return s.jwtSecretKey
+}
+
+func (s Service) JWTLifeTime() time.Duration {
+	return s.jwtLifeTime
+}
+
+func (s Service) JWTCookie() string {
+	return s.jwtCookie
+}
+
+func (s Service) BasicAuthUser() string {
+	return s.basicAuthUser
+}
+
+func (s Service) BasicAuthPassword() string {
+	return s.basicAuthPassword
+}
+
+func (s Service) StaticVersion() uint16 {
+	return s.staticVersion
+}
+
+func (s Service) LoggerStdoutEnabled() bool {
+	return s.loggerStdoutEnabled
+}
+
+func (s Service) LoggerStdoutLevel() string {
+	return s.loggerStdoutLevel
+}
+
+func (s Service) LoggerStdoutAddSource() bool {
+	return s.loggerStdoutAddSource
+}
+
+func (s Service) LoggerEmailEnabled() bool {
+	return s.loggerEmailEnabled
+}
+
+func (s Service) LoggerEmailLevel() string {
+	return s.loggerEmailLevel
+}
+
+func (s Service) LoggerEmailAddSource() bool {
+	return s.loggerEmailAddSource
+}
+
+func (s Service) LoggerEmailSubject() string {
+	return s.loggerEmailSubject
+}
+
+func (s Service) LoggerEmailRecipient() string {
+	return s.loggerEmailRecipient
+}
+
+func (s Service) LoginRateLimiterMaxRequests() uint16 {
+	return s.loginRateLimiterMaxRequests
+}
+
+func (s Service) LoginRateLimiterExpiration() time.Duration {
+	return s.loginRateLimiterExpiration
+}

+ 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
-}

+ 0 - 208
internal/services/env/env.go

@@ -1,208 +0,0 @@
-package env
-
-import (
-	"flag"
-
-	"github.com/spf13/viper"
-)
-
-const defaultConfigPath = "../../.env"
-
-type Service struct {
-	appPort               string
-	dbHost                string
-	dbPort                string
-	dbName                string
-	dbUser                string
-	dbPassword            string
-	dbMaxOpenConns        int
-	dbMaxIdleConns        int
-	dbMaxConnLifetime     int
-	dbMaxIdleConnLifetime int
-	corsAllowOrigins      string
-	corsAllowMethods      string
-	jwtSecretKey          string
-	jwtLifeTime           int
-	jwtCookie             string
-	basicAuthUser         string
-	basicAuthPassword     string
-	smtpHost              string
-	smtpPort              string
-	smtpUser              string
-	smtpPassword          string
-	errorsEmail           string
-	gaKey                 string
-	staticVersion         int
-}
-
-func Init() (*Service, error) {
-	var configPath string
-	flag.StringVar(&configPath, "config", "", "Path to .env config file")
-	flag.Parse()
-
-	if configPath == "" {
-		configPath = defaultConfigPath
-	}
-
-	viper.SetConfigFile(configPath)
-	viper.SetConfigType("env")
-	viper.AutomaticEnv()
-
-	if err := viper.ReadInConfig(); err != nil {
-		return nil, err
-	}
-
-	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"`
-	}{}
-
-	if err := viper.Unmarshal(&s); err != nil {
-		return nil, err
-	}
-
-	return &Service{
-		appPort:               s.AppPort,
-		dbHost:                s.DBHost,
-		dbPort:                s.DBPort,
-		dbName:                s.DBName,
-		dbUser:                s.DBUser,
-		dbPassword:            s.DBPassword,
-		dbMaxOpenConns:        s.DBMaxOpenConns,
-		dbMaxIdleConns:        s.DBMaxIdleConns,
-		dbMaxConnLifetime:     s.DBMaxConnLifetime,
-		dbMaxIdleConnLifetime: s.DBMaxIdleConnLifetime,
-		corsAllowOrigins:      s.CORSAllowOrigins,
-		corsAllowMethods:      s.CORSAllowMethods,
-		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,
-	}, nil
-}
-
-func (e *Service) AppPort() string {
-	return e.appPort
-}
-
-func (e *Service) DBHost() string {
-	return e.dbHost
-}
-
-func (e *Service) DBPort() string {
-	return e.dbPort
-}
-
-func (e *Service) DBName() string {
-	return e.dbName
-}
-
-func (e *Service) DBUser() string {
-	return e.dbUser
-}
-
-func (e *Service) DBPassword() string {
-	return e.dbPassword
-}
-
-func (e *Service) CORSAllowOrigins() string {
-	return e.corsAllowOrigins
-}
-
-func (e *Service) CORSAllowMethods() string {
-	return e.corsAllowMethods
-}
-
-func (e *Service) DBMaxOpenConns() int {
-	return e.dbMaxOpenConns
-}
-
-func (e *Service) DBMaxIdleConns() int {
-	return e.dbMaxIdleConns
-}
-
-func (e *Service) DBMaxConnLifetime() int {
-	return e.dbMaxConnLifetime
-}
-
-func (e *Service) DBMaxIdleConnLifetime() int {
-	return e.dbMaxIdleConnLifetime
-}
-
-func (e *Service) SMTPHost() string {
-	return e.smtpHost
-}
-
-func (e *Service) SMTPPort() string {
-	return e.smtpPort
-}
-
-func (e *Service) SMTPUser() string {
-	return e.smtpUser
-}
-
-func (e *Service) SMTPPassword() string {
-	return e.smtpPassword
-}
-
-func (e *Service) JWTSecretKey() string {
-	return e.jwtSecretKey
-}
-
-func (e *Service) JWTCookie() string {
-	return e.jwtCookie
-}
-
-func (e *Service) JWTLifetime() int {
-	return e.jwtLifeTime
-}
-
-func (e *Service) ErrorsEmail() string {
-	return e.errorsEmail
-}
-
-func (e *Service) BasicAuthUser() string {
-	return e.basicAuthUser
-}
-
-func (e *Service) BasicAuthPassword() string {
-	return e.basicAuthPassword
-}
-
-func (e *Service) StaticVersion() int {
-	return e.staticVersion
-}
-
-func (e *Service) GAKey() string {
-	return e.gaKey
-}

+ 124 - 72
internal/services/handler/admin/article.go

@@ -3,45 +3,54 @@ 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"
 
-	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers"
+	helper "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers/i18n"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers/request"
 	"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"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/i18n"
 )
 
 type (
 	CacheService interface {
-		FlushAll()
+		Delete(key string)
+	}
+
+	TransactionManager interface {
+		ReadCommitted(context.Context, func(ctx context.Context) error) error
 	}
 
 	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, id uint64, tagIDs []uint64) error
+		Delete(ctx context.Context, id uint64, tagIDs []uint64) error
+		DeleteByArticleID(ctx context.Context, id uint64) error
 	}
 
 	AuthService interface {
@@ -51,56 +60,58 @@ type (
 		GetClaims(fctx *fiber.Ctx) jwt.MapClaims
 	}
 
-	EnvService interface {
+	ConfigService interface {
 		JWTCookie() string
-		JWTLifetime() int
+		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
+		Add(ctx context.Context, username string, password string) (uint64, error)
+		UpdatePassword(ctx context.Context, id uint64, newPassword string) error
 	}
 )
 
-const errArticleExists = "Статья с данным URL уже существует"
-
 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
 		}
 
 		return ctx.Render("admin/article", fiber.Map{
-			"articles": mapper.ToArticleDTOList(articles),
+			"articles": mapper.ToArticleDTOList(ctx, articles),
 			"section":  "article",
 		}, "admin/_layout")
 	}
 }
 
 func AddArticleHandler(
+	tm TransactionManager,
 	articleRepository ArticleRepository,
 	tagRepository TagRepository,
 	articleTagRepository ArticleTagRepository,
-	cacheService CacheService,
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
+		lang := i18n.Language(fctx)
 		var validate = validator.New()
 		validateErrors := make(map[string]string)
 
-		trans, err := helpers.GetDefaultTranslator(validate)
+		trans, err := helper.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,21 +119,23 @@ func AddArticleHandler(
 
 		if fctx.Method() == fiber.MethodPost {
 			if err = fctx.BodyParser(&form); err != nil {
+				logger.Info(ctx, err.Error())
 				return err
 			}
 
 			if err = validate.Struct(form); err != nil {
-				validateErrors = helpers.FormatValidateErrors(err, trans)
+				validateErrors = helper.FormatValidateErrors(err, trans)
 			}
 
 			if res, _ := articleRepository.GetByURL(ctx, form.URL); res != nil {
-				validateErrors["ArticleForm.URL"] = errArticleExists
+				validateErrors["ArticleForm.URL"] = i18n.T(lang, "err_article_exists")
 			}
 
-			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,21 +149,29 @@ 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 {
-					return articleErr
-				}
+				err = tm.ReadCommitted(ctx, func(ctx context.Context) error {
+					articleID, txErr := articleRepository.Add(ctx, *articleModel)
+					if txErr != nil {
+						return txErr
+					}
 
-				if len(form.Tags) > 0 {
-					if err = articleTagRepository.Add(ctx, articleID, tagIDs); err != nil {
-						return err
+					if len(form.Tags) > 0 {
+						if txErr = articleTagRepository.Add(ctx, articleID, tagIDs); txErr != nil {
+							return txErr
+						}
 					}
-				}
 
-				cacheService.FlushAll()
+					return nil
+				})
+
+				if err != nil {
+					logger.Error(ctx, err.Error())
+					return err
+				}
 
 				return fctx.Redirect("/admin")
 			}
@@ -161,13 +182,14 @@ func AddArticleHandler(
 			"errors":     validateErrors,
 			"tags":       tagsDTO,
 			"section":    "article",
-			"title":      "Добавление статьи",
+			"title":      i18n.T(lang, "admin_add_article_title"),
 			"show_apply": false,
 		}, "admin/_layout")
 	}
 }
 
 func EditArticleHandler(
+	tm TransactionManager,
 	articleRepository ArticleRepository,
 	tagRepository TagRepository,
 	articleTagRepository ArticleTagRepository,
@@ -175,34 +197,41 @@ func EditArticleHandler(
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
+		lang := i18n.Language(fctx)
 		var validate = validator.New()
 		validateErrors := make(map[string]string)
 
-		trans, err := helpers.GetDefaultTranslator(validate)
+		trans, err := helper.GetDefaultTranslator(validate)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
-		ID, err := strconv.Atoi(fctx.Params("id"))
+		id, err := request.ConvertToUint64(fctx, "id")
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
-		article, err := articleRepository.GetByID(ctx, ID)
+		article, err := articleRepository.GetByID(ctx, id)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
+
 		if article == nil {
 			return fiber.ErrNotFound
 		}
 
-		articleTags, err := tagRepository.GetByArticleID(ctx, ID)
+		articleTags, err := tagRepository.GetByArticleID(ctx, id)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
 		tags, err := tagRepository.GetAll(ctx)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
@@ -213,27 +242,28 @@ func EditArticleHandler(
 			form = mapper.ToArticleForm(*article, articleTags)
 		} else if fctx.Method() == fiber.MethodPost {
 			form = &models.ArticleForm{
-				ID:         ID,
-				ActiveTags: make(map[int]bool),
+				ID:         id,
+				ActiveTags: make(map[uint64]bool),
 			}
 
 			if err = fctx.BodyParser(form); err != nil {
+				logger.Info(ctx, err.Error())
 				return err
 			}
 
 			if err = validate.Struct(*form); err != nil {
-				validateErrors = helpers.FormatValidateErrors(err, trans)
+				validateErrors = helper.FormatValidateErrors(err, trans)
 			}
 
 			if res, _ := articleRepository.GetByURL(ctx, form.URL); res != nil {
-				if res.ID != ID {
-					validateErrors["ArticleForm.URL"] = errArticleExists
+				if res.ID != id {
+					validateErrors["ArticleForm.URL"] = i18n.T(lang, "err_article_exists")
 				}
 			}
 
-			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 {
 					return tagErr
 				}
@@ -248,43 +278,53 @@ func EditArticleHandler(
 			if len(validateErrors) == 0 {
 				articleModel, err := mapper.ToArticle(*form)
 				if err != nil {
+					logger.Error(ctx, err.Error())
 					return err
 				}
 
-				err = articleRepository.Update(ctx, *articleModel)
-				if err != nil {
-					return err
-				}
-
-				var tagsToAdd, tagsToDelete []int
+				var tagsToAdd, tagsToDelete []uint64
 
 				oldTagsMap := make(map[int]struct{}, len(articleTags))
 				for i := range articleTags {
-					oldTagsMap[articleTags[i].ID] = struct{}{}
+					oldTagsMap[int(articleTags[i].ID)] = struct{}{}
 					if _, ok := form.ActiveTags[articleTags[i].ID]; !ok {
 						tagsToDelete = append(tagsToDelete, articleTags[i].ID)
 					}
 				}
 
 				for i := range tagIDs {
-					if _, ok := oldTagsMap[tagIDs[i]]; !ok {
+					if _, ok := oldTagsMap[int(tagIDs[i])]; !ok {
 						tagsToAdd = append(tagsToAdd, tagIDs[i])
 					}
 				}
 
-				if len(tagsToAdd) > 0 {
-					if err = articleTagRepository.Add(ctx, ID, tagsToAdd); err != nil {
-						return err
+				err = tm.ReadCommitted(ctx, func(ctx context.Context) error {
+					txErr := articleRepository.Update(ctx, *articleModel)
+					if txErr != nil {
+						return txErr
+					}
+
+					if len(tagsToAdd) > 0 {
+						if txErr = articleTagRepository.Add(ctx, id, tagsToAdd); txErr != nil {
+							return txErr
+						}
 					}
-				}
 
-				if len(tagsToDelete) > 0 {
-					if err = articleTagRepository.Delete(ctx, ID, tagsToDelete); err != nil {
-						return err
+					if len(tagsToDelete) > 0 {
+						if txErr = articleTagRepository.Delete(ctx, id, tagsToDelete); txErr != nil {
+							return txErr
+						}
 					}
+
+					return nil
+				})
+
+				if err != nil {
+					logger.Error(ctx, err.Error())
+					return err
 				}
 
-				cacheService.FlushAll()
+				cacheService.Delete(handler.ArticleCacheKey + string(i18n.Ru) + article.URL)
 
 				if fctx.FormValue("action", "save") == "save" {
 					return fctx.Redirect("/admin")
@@ -298,40 +338,52 @@ func EditArticleHandler(
 			"tags":       tagsDTO,
 			"show_apply": true,
 			"section":    "article",
-			"title":      "Редактирование статьи",
+			"title":      i18n.T(lang, "admin_edit_article_title"),
 		}, "admin/_layout")
 	}
 }
 
 func DeleteArticleHandler(
+	tm TransactionManager,
 	articleRepository ArticleRepository,
 	articleTagRepository ArticleTagRepository,
 	cacheService CacheService,
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
-		ID, err := strconv.Atoi(fctx.Params("id"))
+		id, err := request.ConvertToUint64(fctx, "id")
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
-		article, err := articleRepository.GetByID(ctx, ID)
+		article, err := articleRepository.GetByID(ctx, id)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
 		if fctx.Method() == fiber.MethodPost {
-			err = articleTagRepository.DeleteByArticleID(ctx, ID)
-			if err != nil {
-				return err
-			}
+			err = tm.ReadCommitted(ctx, func(ctx context.Context) error {
+				txErr := articleTagRepository.DeleteByArticleID(ctx, id)
+				if txErr != nil {
+					return txErr
+				}
+
+				txErr = articleRepository.Delete(ctx, id)
+				if txErr != nil {
+					return txErr
+				}
+
+				return nil
+			})
 
-			err = articleRepository.Delete(ctx, ID)
 			if err != nil {
+				logger.Error(ctx, err.Error())
 				return err
 			}
 
-			cacheService.FlushAll()
+			cacheService.Delete(handler.ArticleCacheKey + string(i18n.Ru) + article.URL)
 
 			return fctx.Redirect("/admin")
 		}

+ 9 - 5
internal/services/handler/admin/auth.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"time"
 
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
 	"github.com/go-playground/validator/v10"
 	"github.com/gofiber/fiber/v2"
 
@@ -12,7 +13,7 @@ import (
 )
 
 func LoginHandler(
-	envService EnvService,
+	configService ConfigService,
 	authService AuthService,
 	userRepository UserRepository,
 ) fiber.Handler {
@@ -25,6 +26,7 @@ func LoginHandler(
 
 		if fctx.Method() == fiber.MethodPost {
 			if err := fctx.BodyParser(&form); err != nil {
+				logger.Info(ctx, err.Error())
 				return err
 			}
 
@@ -36,6 +38,7 @@ func LoginHandler(
 				user, err := userRepository.Get(ctx, form.Username)
 				if err != nil {
 					if !errors.Is(err, sql.ErrNoRows) {
+						logger.Error(ctx, err.Error())
 						return err
 					}
 					hasErrors = true
@@ -45,13 +48,14 @@ func LoginHandler(
 					if authService.IsCorrectPassword(form.Password, user.Password) {
 						token, err := authService.GenerateToken(*user)
 						if err != nil {
+							logger.Error(ctx, err.Error())
 							return err
 						}
 
 						cookie := new(fiber.Cookie)
-						cookie.Name = envService.JWTCookie()
+						cookie.Name = configService.JWTCookie()
 						cookie.Value = token
-						cookie.Expires = time.Now().Add(time.Duration(envService.JWTLifetime()) * time.Second)
+						cookie.Expires = time.Now().Add(configService.JWTLifeTime())
 						fctx.Cookie(cookie)
 
 						return fctx.Redirect("/admin")
@@ -69,11 +73,11 @@ func LoginHandler(
 }
 
 func LogoutHandler(
-	envService EnvService,
+	configService ConfigService,
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		cookie := new(fiber.Cookie)
-		cookie.Name = envService.JWTCookie()
+		cookie.Name = configService.JWTCookie()
 		cookie.Expires = time.Now().Add(-1 * time.Second)
 		fctx.Cookie(cookie)
 

+ 38 - 26
internal/services/handler/admin/tag.go

@@ -1,24 +1,25 @@
 package admin
 
 import (
-	"strconv"
-
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
 	"github.com/go-playground/validator/v10"
 	"github.com/gofiber/fiber/v2"
 
-	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers"
+	helper "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers/i18n"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers/request"
 	"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"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/i18n"
 )
 
-const errTagExists = "Тег с данным URL уже существует"
-
 func TagHandler(
 	tagRepository TagRepository,
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		tags, err := tagRepository.GetAll(fctx.Context())
 		if err != nil {
+			logger.Error(fctx.Context(), err.Error())
 			return err
 		}
 
@@ -31,40 +32,41 @@ func TagHandler(
 
 func AddTagHandler(
 	tagRepository TagRepository,
-	cacheService CacheService,
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
+		lang := i18n.Language(fctx)
 		var form models.TagForm
 		var validate = validator.New()
 		validateErrors := make(map[string]string)
 
-		trans, err := helpers.GetDefaultTranslator(validate)
+		trans, err := helper.GetDefaultTranslator(validate)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
 		if fctx.Method() == fiber.MethodPost {
 			if err = fctx.BodyParser(&form); err != nil {
+				logger.Info(ctx, err.Error())
 				return err
 			}
 
 			if err = validate.Struct(form); err != nil {
-				validateErrors = helpers.FormatValidateErrors(err, trans)
+				validateErrors = helper.FormatValidateErrors(err, trans)
 			}
 
 			if res, _ := tagRepository.GetByURL(ctx, form.URL); res != nil {
-				validateErrors["TagForm.URL"] = errTagExists
+				validateErrors["TagForm.URL"] = i18n.T(lang, "err_tag_exists")
 			}
 
 			if len(validateErrors) == 0 {
 				err = tagRepository.Add(ctx, mapper.ToTag(form))
 				if err != nil {
+					logger.Error(ctx, err.Error())
 					return err
 				}
 
-				cacheService.FlushAll()
-
 				return fctx.Redirect("/admin/tag")
 			}
 		}
@@ -73,7 +75,7 @@ func AddTagHandler(
 			"form":    form,
 			"errors":  validateErrors,
 			"section": "tag",
-			"title":   "Добавление тега",
+			"title":   i18n.T(lang, "admin_add_tag_title"),
 		}, "admin/_layout")
 	}
 }
@@ -84,52 +86,58 @@ func EditTagHandler(
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
+		lang := i18n.Language(fctx)
 		var validate = validator.New()
 		validateErrors := make(map[string]string)
 
-		trans, err := helpers.GetDefaultTranslator(validate)
+		trans, err := helper.GetDefaultTranslator(validate)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
-		ID, err := strconv.Atoi(fctx.Params("id"))
+		id, err := request.ConvertToUint64(fctx, "id")
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
-		tag, err := tagRepository.GetByID(ctx, ID)
+		tag, err := tagRepository.GetByID(ctx, id)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
 		form := models.TagForm{
-			ID:  ID,
+			ID:  id,
 			Tag: tag.Tag,
 			URL: tag.URL,
 		}
 
 		if fctx.Method() == fiber.MethodPost {
 			if err = fctx.BodyParser(&form); err != nil {
+				logger.Info(ctx, err.Error())
 				return err
 			}
 
 			if err = validate.Struct(form); err != nil {
-				validateErrors = helpers.FormatValidateErrors(err, trans)
+				validateErrors = helper.FormatValidateErrors(err, trans)
 			}
 
 			if res, _ := tagRepository.GetByURL(ctx, form.URL); res != nil {
-				if res.ID != ID {
-					validateErrors["TagForm.URL"] = errTagExists
+				if res.ID != id {
+					validateErrors["TagForm.URL"] = i18n.T(lang, "err_tag_exists")
 				}
 			}
 
 			if len(validateErrors) == 0 {
 				err = tagRepository.Update(ctx, mapper.ToTag(form))
 				if err != nil {
+					logger.Error(ctx, err.Error())
 					return err
 				}
 
-				cacheService.FlushAll()
+				cacheService.Delete(handler.TagCacheKey + string(i18n.Ru) + tag.URL)
 
 				return fctx.Redirect("/admin/tag")
 			}
@@ -139,7 +147,7 @@ func EditTagHandler(
 			"form":    form,
 			"errors":  validateErrors,
 			"section": "tag",
-			"title":   "Редактирование тега",
+			"title":   i18n.T(lang, "admin_edit_tag_title"),
 		}, "admin/_layout")
 	}
 }
@@ -150,28 +158,32 @@ func DeleteTagHandler(
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
-		ID, err := strconv.Atoi(fctx.Params("id"))
+		id, err := request.ConvertToUint64(fctx, "id")
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
-		tag, err := tagRepository.GetByID(ctx, ID)
+		tag, err := tagRepository.GetByID(ctx, id)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
-		used, err := tagRepository.IsUsed(ctx, ID)
+		used, err := tagRepository.IsUsed(ctx, id)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
 		if fctx.Method() == fiber.MethodPost {
-			err = tagRepository.Delete(ctx, ID)
+			err = tagRepository.Delete(ctx, id)
 			if err != nil {
+				logger.Error(ctx, err.Error())
 				return err
 			}
 
-			cacheService.FlushAll()
+			cacheService.Delete(handler.TagCacheKey + string(i18n.Ru) + tag.URL)
 
 			return fctx.Redirect("/admin/tag")
 		}

+ 13 - 8
internal/services/handler/admin/user.go

@@ -1,28 +1,29 @@
 package admin
 
 import (
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
 	"github.com/go-playground/validator/v10"
 	"github.com/gofiber/fiber/v2"
 
-	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers"
+	helper "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers/i18n"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/auth"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/i18n"
 )
 
-// nolint
-const errIncorrectOldPassword = "Неверный старый пароль"
-
 func ChangePassword(
 	authService AuthService,
 	userRepository UserRepository,
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
+		lang := i18n.Language(fctx)
 		var validate = validator.New()
 		validateErrors := make(map[string]string)
 
-		trans, err := helpers.GetDefaultTranslator(validate)
+		trans, err := helper.GetDefaultTranslator(validate)
 		if err != nil {
+			logger.Error(ctx, err.Error())
 			return err
 		}
 
@@ -30,11 +31,12 @@ func ChangePassword(
 
 		if fctx.Method() == fiber.MethodPost {
 			if err = fctx.BodyParser(&form); err != nil {
+				logger.Info(ctx, err.Error())
 				return err
 			}
 
 			if err = validate.Struct(form); err != nil {
-				validateErrors = helpers.FormatValidateErrors(err, trans)
+				validateErrors = helper.FormatValidateErrors(err, trans)
 			}
 
 			if len(validateErrors) == 0 {
@@ -42,23 +44,26 @@ func ChangePassword(
 
 				user, err := userRepository.Get(ctx, claims[auth.ClaimNameKey].(string))
 				if err != nil {
+					logger.Error(ctx, err.Error())
 					return err
 				}
 
 				if authService.IsCorrectPassword(form.OldPassword, user.Password) {
 					newPassword, err := authService.GeneratePasswordHash(form.NewPassword)
 					if err != nil {
+						logger.Error(ctx, err.Error())
 						return err
 					}
 
 					if err = userRepository.UpdatePassword(ctx, user.ID, newPassword); err != nil {
+						logger.Error(ctx, err.Error())
 						return err
 					}
 
 					return fctx.Redirect("/admin")
 				}
 
-				validateErrors["ChangePasswordForm.OldPassword"] = errIncorrectOldPassword
+				validateErrors["ChangePasswordForm.OldPassword"] = i18n.T(lang, "admin_incorrect_old_password")
 			}
 		}
 
@@ -66,7 +71,7 @@ func ChangePassword(
 			"form":    form,
 			"errors":  validateErrors,
 			"section": "change_password",
-			"title":   "Изменение пароля",
+			"title":   i18n.T(lang, "admin_change_password_title"),
 		}, "admin/_layout")
 	}
 }

+ 19 - 10
internal/services/handler/article.go

@@ -2,16 +2,19 @@ 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"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/i18n"
 )
 
 const (
+	ArticleCacheKey  = "article"
 	maxArticlesCount = 3
 	articleParam     = "article"
-	articleCacheKey  = "article"
 )
 
 func ArticleHandler(
@@ -21,16 +24,20 @@ func ArticleHandler(
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
-		articleReq := fctx.Params(articleParam)
+		url := fctx.Params(articleParam)
+		lang := i18n.Language(fctx)
 
-		renderData, found := cacheService.Get(articleCacheKey + articleReq)
+		renderData, found := cacheService.Get(ArticleCacheKey + string(lang) + 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
 			}
 
@@ -38,11 +45,12 @@ func ArticleHandler(
 				return fiber.ErrNotFound
 			}
 
-			articleDTO := mapper.ToArticleDTO(*article)
+			articleDTO := mapper.ToArticleDTO(fctx, *article)
 
 			// All used tags
 			tags, err := tagRepository.GetAllUsed(ctx)
 			if err != nil {
+				logger.Error(ctx, err.Error())
 				return err
 			}
 
@@ -51,6 +59,7 @@ func ArticleHandler(
 			// Last articles
 			articles, err := articleRepository.GetAllPreview(ctx)
 			if err != nil {
+				logger.Error(ctx, err.Error())
 				return err
 			}
 			if len(articles) > maxArticlesCount {
@@ -58,16 +67,16 @@ func ArticleHandler(
 			}
 
 			renderData = fiber.Map{
-				"headTitle":       "От слона к суслику - статьи про PHP, Go, алгоритмы",
+				"headTitle":       i18n.T(lang, "head_title"),
 				"headDescription": articleDTO.MetaDescription,
 				"headKeywords":    articleDTO.MetaKeywords,
-				"pageTitle":       "Статья<br>" + articleDTO.Title,
+				"pageTitle":       i18n.T(lang, "article_page_title", articleDTO.Title),
 				"article":         articleDTO,
-				"sidebarArticles": mapper.ToArticlePreviewDTOList(articles),
+				"sidebarArticles": mapper.ToArticlePreviewDTOList(fctx, articles),
 				"sidebarTags":     tagsDTO,
 			}
 
-			cacheService.Set(articleCacheKey+articleReq, renderData)
+			cacheService.Set(ArticleCacheKey+string(lang)+url, renderData, nil)
 		}
 
 		return fctx.Render("article", renderData, "_layout")

+ 25 - 26
internal/services/handler/article_test.go

@@ -3,9 +3,7 @@ package handler
 import (
 	"context"
 	"database/sql"
-	"errors"
 	"net/http/httptest"
-	"strconv"
 	"testing"
 
 	"github.com/brianvoe/gofakeit/v6"
@@ -13,7 +11,7 @@ import (
 	"github.com/gojuno/minimock/v3"
 	"github.com/stretchr/testify/assert"
 
-	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers/test"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/handler/mocks"
 )
@@ -27,13 +25,14 @@ func TestArticleHandler(t *testing.T) {
 	}
 
 	var (
-		articleID   = gofakeit.Number(1, 100)
+		articleID   = gofakeit.Uint64()
+		articleURL  = gofakeit.Word()
 		publishTime = gofakeit.Date()
-		internalErr = errors.New(gofakeit.Phrase())
+		internalErr = gofakeit.Error()
 
 		article = models.Article{
 			ID:          articleID,
-			URL:         gofakeit.URL(),
+			URL:         articleURL,
 			Title:       gofakeit.Phrase(),
 			Text:        gofakeit.Phrase(),
 			PublishTime: publishTime,
@@ -44,7 +43,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 +53,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 +66,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 +74,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 +82,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 +90,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 +113,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 +134,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 +146,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 +164,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 +174,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 +192,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 +202,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 +220,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 +230,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 +250,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 +260,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 +280,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)
@@ -296,7 +295,7 @@ func TestArticleHandler(t *testing.T) {
 			t.Parallel()
 
 			mc := minimock.NewController(t)
-			fiberApp := fiber.New(helpers.GetFiberTestConfig())
+			fiberApp := fiber.New(test.GetFiberTestConfig())
 			fiberReq := httptest.NewRequest(tt.req.method, tt.req.route, nil)
 
 			fiberApp.Get("/article/:article", ArticleHandler(

+ 16 - 9
internal/services/handler/main_page.go

@@ -6,23 +6,26 @@ 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"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/i18n"
 )
 
 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,23 +41,27 @@ func MainPageHandler(
 	articleRepository ArticleRepository,
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
-		renderData, found := cacheService.Get(allPreviewArticlesCacheKey)
+		ctx := fctx.Context()
+		lang := i18n.Language(fctx)
+
+		renderData, found := cacheService.Get(allPreviewArticlesCacheKey + string(lang))
 
 		if !found {
 			articles, err := articleRepository.GetAllPreview(fctx.Context())
 			if err != nil {
+				logger.Error(ctx, err.Error())
 				return err
 			}
 
 			renderData = fiber.Map{
-				"headTitle":       "От слона к суслику - статьи про PHP, Go, алгоритмы",
-				"headDescription": "список статей",
-				"headKeywords":    "Дмитрий Гнатенко, программист, PHP, Go, Golang, программирование, статьи, блог",
-				"pageTitle":       "Список статей",
-				"articles":        mapper.ToArticlePreviewDTOList(articles),
+				"headTitle":       i18n.T(lang, "head_title"),
+				"pageTitle":       i18n.T(lang, "main_page_title"),
+				"headDescription": i18n.T(lang, "main_page_desc"),
+				"headKeywords":    i18n.T(lang, "main_page_keywords"),
+				"articles":        mapper.ToArticlePreviewDTOList(fctx, articles),
 			}
 
-			cacheService.Set(allPreviewArticlesCacheKey, renderData)
+			cacheService.Set(allPreviewArticlesCacheKey+string(lang), renderData, nil)
 		}
 
 		return fctx.Render("index", renderData, "_layout")

+ 5 - 5
internal/services/handler/main_page_test.go

@@ -11,7 +11,7 @@ import (
 	"github.com/gojuno/minimock/v3"
 	"github.com/stretchr/testify/assert"
 
-	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers/test"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/handler/mocks"
 )
@@ -30,7 +30,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 +38,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 +46,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,
@@ -114,7 +114,7 @@ func TestMainPageHandler(t *testing.T) {
 			t.Parallel()
 
 			mc := minimock.NewController(t)
-			fiberApp := fiber.New(helpers.GetFiberTestConfig())
+			fiberApp := fiber.New(test.GetFiberTestConfig())
 			fiberReq := httptest.NewRequest(tt.req.method, tt.req.route, nil)
 
 			fiberApp.Get("/", MainPageHandler(

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

@@ -1,4 +1,4 @@
-// Code generated by http://github.com/gojuno/minimock (v3.3.13). DO NOT EDIT.
+// Code generated by http://github.com/gojuno/minimock (v3.3.14). DO NOT EDIT.
 
 package mocks
 
@@ -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)
 

+ 46 - 17
internal/services/handler/mocks/cache_service_minimock.go

@@ -1,4 +1,4 @@
-// Code generated by http://github.com/gojuno/minimock (v3.3.13). DO NOT EDIT.
+// Code generated by http://github.com/gojuno/minimock (v3.3.14). DO NOT EDIT.
 
 package mocks
 
@@ -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)
 
 }
 

+ 1 - 1
internal/services/handler/mocks/tag_repository_minimock.go

@@ -1,4 +1,4 @@
-// Code generated by http://github.com/gojuno/minimock (v3.3.13). DO NOT EDIT.
+// Code generated by http://github.com/gojuno/minimock (v3.3.14). DO NOT EDIT.
 
 package mocks
 

+ 22 - 12
internal/services/handler/tag.go

@@ -2,14 +2,19 @@ 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"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/i18n"
 )
 
-const tagParam = "tag"
-const tagCacheKey = "tag"
+const (
+	TagCacheKey = "tag"
+	tagParam    = "tag"
+)
 
 func TagHandler(
 	cacheService CacheService,
@@ -18,33 +23,38 @@ func TagHandler(
 ) fiber.Handler {
 	return func(fctx *fiber.Ctx) error {
 		ctx := fctx.Context()
-		tagReq := fctx.Params(tagParam)
+		url := fctx.Params(tagParam)
+		lang := i18n.Language(fctx)
 
-		renderData, found := cacheService.Get(tagCacheKey + tagReq)
+		renderData, found := cacheService.Get(TagCacheKey + string(lang) + 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
 			}
 
 			renderData = fiber.Map{
-				"headTitle":       "От слона к суслику - статьи про PHP, Go, алгоритмы",
-				"headDescription": "статьи с тегом " + tag.Tag,
-				"headKeywords":    "программирование, статьи, блог, " + tag.Tag,
-				"pageTitle":       "Тег<br>" + tag.Tag,
-				"articles":        mapper.ToArticlePreviewDTOList(articles),
+				"headTitle":       i18n.T(lang, "head_title"),
+				"pageTitle":       i18n.T(lang, "tag_page_title", tag.Tag),
+				"headDescription": i18n.T(lang, "tag_page_desc", tag.Tag),
+				"headKeywords":    i18n.T(lang, "tag_page_keywords", tag.Tag),
+				"articles":        mapper.ToArticlePreviewDTOList(fctx, articles),
 			}
 
-			cacheService.Set(tagCacheKey+tagReq, renderData)
+			cacheService.Set(TagCacheKey+string(lang)+url, renderData, nil)
 		}
 
 		return fctx.Render("tag", renderData, "_layout")

+ 15 - 15
internal/services/handler/tag_test.go

@@ -5,7 +5,6 @@ import (
 	"database/sql"
 	"errors"
 	"net/http/httptest"
-	"strconv"
 	"testing"
 
 	"github.com/brianvoe/gofakeit/v6"
@@ -13,7 +12,7 @@ import (
 	"github.com/gojuno/minimock/v3"
 	"github.com/stretchr/testify/assert"
 
-	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/helpers/test"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/handler/mocks"
 )
@@ -27,7 +26,8 @@ func TestTagHandler(t *testing.T) {
 	}
 
 	var (
-		tagID       = gofakeit.Number(1, 100)
+		tagID       = gofakeit.Uint64()
+		tagURL      = gofakeit.Word()
 		publishTime = gofakeit.Date()
 		internalErr = errors.New(gofakeit.Phrase())
 
@@ -36,7 +36,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 +58,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 +72,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 +80,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 +91,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 +104,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 +118,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 +131,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 +144,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 +157,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 +165,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)
 
@@ -179,7 +179,7 @@ func TestTagHandler(t *testing.T) {
 			t.Parallel()
 
 			mc := minimock.NewController(t)
-			fiberApp := fiber.New(helpers.GetFiberTestConfig())
+			fiberApp := fiber.New(test.GetFiberTestConfig())
 			fiberReq := httptest.NewRequest(tt.req.method, tt.req.route, nil)
 
 			fiberApp.Get("/tag/:tag", TagHandler(

+ 77 - 0
internal/services/i18n/i18n.go

@@ -0,0 +1,77 @@
+package i18n
+
+import (
+	"context"
+	_ "embed"
+	"encoding/json"
+	"fmt"
+	"sync"
+
+	"git.dmitriygnatenko.ru/dima/go-common/logger"
+	"github.com/gofiber/fiber/v2"
+)
+
+//go:embed ru.json
+var ruJsonContent []byte
+
+type Lang string
+
+const (
+	CtxLanguageKey      = "Language"
+	Ru             Lang = "ru"
+	Default        Lang = Ru
+)
+
+var (
+	once sync.Once
+	i18n *I18n
+)
+
+type I18n struct {
+	translations map[Lang]map[string]string
+}
+
+func Init() error {
+	var err error
+
+	once.Do(func() {
+		ruTranslations := make(map[string]string)
+
+		err = json.Unmarshal(ruJsonContent, &ruTranslations)
+		if err != nil {
+			return
+		}
+
+		i18n = &I18n{translations: map[Lang]map[string]string{
+			Ru: ruTranslations,
+		}}
+	})
+
+	return err
+}
+
+func T(lang Lang, key string, args ...any) string {
+	if i18n == nil {
+		logger.Error(context.Background(), "i18n not initialized")
+		return ""
+	}
+
+	if i18n.translations[lang] == nil {
+		logger.Warnf(context.Background(), "i18n: language %s not initialized", lang)
+		return ""
+	}
+
+	if len(args) == 0 {
+		return i18n.translations[lang][key]
+	}
+
+	return fmt.Sprintf(i18n.translations[lang][key], args...)
+}
+
+func Language(c *fiber.Ctx) Lang {
+	if lang, ok := c.Locals(CtxLanguageKey).(Lang); ok {
+		return lang
+	}
+
+	return Default
+}

+ 34 - 0
internal/services/i18n/ru.json

@@ -0,0 +1,34 @@
+{
+  "head_title": "От слона к суслику - статьи про PHP, Go, алгоритмы",
+  "main_page_title": "Список статей",
+  "main_page_desc": "список статей",
+  "main_page_keywords": "PHP, Go, Golang, программирование, статьи, блог",
+  "article_page_title": "Статья<br>%s",
+  "tag_page_title": "Тег<br>%s",
+  "tag_page_desc": "статьи с тегом %s",
+  "tag_page_keywords": "программирование, статьи, блог, %s",
+  "page_not_found_err_title": "Страница не найдена",
+  "page_not_found_err_desc": "Запрашиваемая вами страница не найдена",
+  "internal_err_title": "Внутренняя ошибка",
+  "internal_err_desc": "Внутренняя ошибка сервера, идем исправлять...",
+  "admin_change_password_title": "Изменение пароля",
+  "admin_incorrect_old_password": "Неверный старый пароль",
+  "admin_add_tag_title": "Добавление тега",
+  "admin_edit_tag_title": "Редактирование тега",
+  "err_tag_exists": "Тег с данным URL уже существует",
+  "admin_add_article_title": "Добавление статьи",
+  "admin_edit_article_title": "Редактирование статьи",
+  "err_article_exists": "Статья с данным URL уже существует",
+  "m01": "января",
+  "m02": "февраля",
+  "m03": "марта",
+  "m04": "апреля",
+  "m05": "мая",
+  "m06": "июня",
+  "m07": "июля",
+  "m08": "августа",
+  "m09": "сентября",
+  "m10": "октября",
+  "m11": "ноября",
+  "m12": "декабря"
+}

+ 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">

+ 37 - 15
readme.md

@@ -3,39 +3,61 @@
 config/.env
 
 ```
+# APP
 APP_PORT=8080
 
+# CORS
+CORS_ALLOW_ORIGIN=*
+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
+DB_MAX_IDLE_CONNS=0
+DB_MAX_OPEN_CONN_LIFETIME=0s
+DB_MAX_IDLE_CONN_LIFETIME=0s
 
-CORS_ALLOW_ORIGING=*
-CORS_ALLOW_METHODS=GET,POST,PUT,DELETE
+# Cache
+CACHE_DEFAULT_EXPIRATION=24h
+CACHE_CLEANUP_INTERVAL=1h
 
-SMTP_HOST=smtp.example.com
-SMTP_PORT=25
-SMTP_USER=example@example.com
-SMTP_PASSWORD=5cCd5m2
+# SMTP
+SMTP_HOST=smtp.ru
+SMTP_PORT=2525
+SMTP_USER=example@user.ru
+SMTP_PASSWORD=pass
 
-ERRORS_EMAIL=example@example.com
+# Rate limiter
+LOGIN_RATE_LIMITER_MAX_REQUESTS=10
+LOGIN_RATE_LIMITER_EXPIRATION=30s	
 
+# 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_RECIPIENT=info@dmitriygnatenko.ru
+LOGGER_EMAIL_SUBJECT=Error from dmitriygnatenko.ru
 ```
 
 ### Команды