diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
new file mode 100644
index 0000000..38b99ef
--- /dev/null
+++ b/.github/workflows/lint.yaml
@@ -0,0 +1,34 @@
+name: lint
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ golangci:
+ name: lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+ - name: Setup go
+ uses: actions/setup-go@v4
+ with:
+ go-version: '1.21'
+ cache: false
+ - name: golangci-lint (server)
+ uses: golangci/golangci-lint-action@v3
+ with:
+ version: v1.54.1
+ working-directory: server
+ - name: golangci-lint (client)
+ uses: golangci/golangci-lint-action@v3
+ with:
+ version: v1.54.1
+ working-directory: client
+ - name: golangci-lint (pkg)
+ uses: golangci/golangci-lint-action@v3
+ with:
+ version: v1.54.1
+ working-directory: pkg
\ No newline at end of file
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..8b8cb18
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,38 @@
+name: release
+
+on:
+ release:
+ types: [created]
+
+permissions:
+ contents: write
+ packages: write
+
+jobs:
+ releases-matrix:
+ name: Release Go Binary
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ goos: [linux, windows, darwin]
+ goarch: ["386", amd64, arm64]
+ exclude:
+ - goarch: "386"
+ goos: darwin
+ - goarch: "386"
+ goos: windows
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set APP_VERSION env
+ run: echo APP_VERSION=$(echo ${GITHUB_REF} | rev | cut -d'/' -f 1 | rev ) >> ${GITHUB_ENV}
+ - name: Set BUILD_TIME env
+ run: echo BUILD_TIME=$(date) >> ${GITHUB_ENV}
+ - uses: wangyoucao577/go-release-action@v1
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ goos: ${{ matrix.goos }}
+ goarch: ${{ matrix.goarch }}
+ project_path: "./client/cmd/"
+ extra_files: ./client/config/config.yaml
+ binary_name: "client"
+ ldflags: -X "main.buildVersion=${{ env.APP_VERSION }}" -X "main.buildDate=${{ env.BUILD_TIME }}"
\ No newline at end of file
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100644
index 0000000..453d099
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,160 @@
+name: tests
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+
+ tests:
+ runs-on: ubuntu-latest
+ container: golang:1.21
+
+ services:
+ postgres:
+ image: postgres
+ env:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: gophkeeper
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 5
+ redis:
+ image: redis
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ mailpit:
+ image: axllent/mailpit
+ ports:
+ - 1025:1025
+ - 8025:8025
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+
+ - name: Generate TLS cert and key
+ run: |
+ cd ./test/
+ mkdir integration
+ cd integration/
+ go run /usr/local/go/src/crypto/tls/generate_cert.go -duration=168h -ca=true -host='localhost' $(date +"%b %d %H:%M:%S %Y")
+
+ - name: Build server binary
+ run: |
+ cd server/cmd
+ go build -buildvcs=false -cover -o server
+ cp server ../../test/integration/server
+ rm server
+
+ - name: Build integration tests
+ run: |
+ cd test/
+ go test -c -o test
+ cp test ./integration/test
+ rm test
+
+ - name: Apply migrations
+ run: |
+ go install -tags 'postgres,sqlite' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
+ migrate -source file://server/migrations/postgres -database postgres://postgres:postgres@postgres:5432/gophkeeper?sslmode=disable up
+ migrate -source file://server/migrations/sqlite -database sqlite://vault.db up
+ cp vault.db ./test/integration/vault.db
+ rm vault.db
+
+ - name: "Run integration tests (postgres + sync map cache)"
+ run: |
+ cd ./test/integration/
+ mkdir cover
+ chmod +x server
+ chmod +x test
+ ./test
+ env:
+ TEST_GRPC_HOST: localhost
+ TEST_GRPC_PORT: 8081
+ TEST_MAIL_HOST: mailpit
+ TEST_MAIL_PORT: 8025
+ TEST_REDIS_HOST: redis
+ TEST_REDIS_PORT: 6379
+ TEST_BINARY_PATH: ./server
+ GOCOVERDIR: cover
+ GOPHKEEPER_SERVICE_STORAGE_URI: postgres://postgres:postgres@postgres:5432/gophkeeper?sslmode=disable
+ GOPHKEEPER_GRPC_KEY_FILE_NAME: key.pem
+ GOPHKEEPER_GRPC_CERT_FILE_NAME: cert.pem
+ GOPHKEEPER_GRPC_HOST: localhost
+ GOPHKEEPER_GRPC_PORT: 8081
+ GOPHKEEPER_SMTP_HOST: mailpit
+ GOPHKEEPER_SMTP_PORT: 1025
+ GOPHKEEPER_RTASK_STORAGE_URI: redis://redis:6379/1
+
+ - name: "Converting coverage to legacy text format"
+ if: ${{ success() }}
+ run: |
+ cd ./test/integration/
+ go tool covdata textfmt -i=cover -o integration_coverage_postgres.out
+ rm -rf cover
+ cd ./../
+ cp ./integration/integration_coverage_postgres.out integration_coverage_postgres.out
+ env:
+ GOCOVERDIR: cover
+
+ - name: "Run integration tests (sqlite + redis cache)"
+ run: |
+ cd ./test/integration/
+ mkdir cover
+ chmod +x server
+ chmod +x test
+ ./test
+ env:
+ TEST_GRPC_HOST: localhost
+ TEST_GRPC_PORT: 8081
+ TEST_MAIL_HOST: mailpit
+ TEST_MAIL_PORT: 8025
+ TEST_REDIS_HOST: redis
+ TEST_REDIS_PORT: 6379
+ TEST_BINARY_PATH: ./server
+ GOCOVERDIR: cover
+ GOPHKEEPER_SERVICE_STORAGE_URI: file:vault.db
+ GOPHKEEPER_GRPC_KEY_FILE_NAME: key.pem
+ GOPHKEEPER_GRPC_CERT_FILE_NAME: cert.pem
+ GOPHKEEPER_GRPC_HOST: localhost
+ GOPHKEEPER_GRPC_PORT: 8081
+ GOPHKEEPER_SMTP_HOST: mailpit
+ GOPHKEEPER_SMTP_PORT: 1025
+ GOPHKEEPER_SERVICE_AUTH_CACHE_URI: redis://redis:6379/2
+ GOPHKEEPER_SERVICE_MAIL_CACHE_URI: redis://redis:6379/3
+ GOPHKEEPER_RTASK_STORAGE_URI: redis://redis:6379/1
+
+ - name: "Converting coverage to legacy text format"
+ if: ${{ success() }}
+ run: |
+ cd ./test/integration/
+ go tool covdata textfmt -i=cover -o integration_coverage_sqlite.out
+ rm -rf cover
+ cd ./../
+ cp ./integration/integration_coverage_sqlite.out integration_coverage_sqlite.out
+ env:
+ GOCOVERDIR: cover
+
+ - name: Run unit tests
+ run: |
+ cd ./test/
+ mkdir unit
+ cd ./../server
+ go test -tags fast -coverprofile unit_coverage.out -covermode atomic ./...
+ cp unit_coverage.out ./../test/unit_coverage.out
+ rm unit_coverage.out
+
+ - name: Upload coverage report to Codecov
+ if: ${{ success() }}
+ uses: codecov/codecov-action@v3
+ with:
+ files: ./test/integration_coverage_postgres.out,./test/integration_coverage_sqlite.out,./test/unit_coverage.out
+ token: ${{ secrets.CODECOV_TOKEN }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 5d5a3e7..819f1bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ server/cmd/server
# Go workspace file
go.work*
+
+# Certificates
+*.pem
\ No newline at end of file
diff --git a/.golangci.toml b/.golangci.toml
index 5b83d6d..88a781b 100644
--- a/.golangci.toml
+++ b/.golangci.toml
@@ -19,7 +19,6 @@ disable-all = true
enable = [
"bodyclose",
"dogsled",
- "dupl",
"errcheck",
"goimports",
"goprintffuncname",
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1c3af44
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,49 @@
+.PHONY: build
+build: clean build-client build-server
+
+build-client:
+ cd client/cmd/ && go build -tags=debug -o client
+
+build-server:
+ cd server/cmd/ && go build -o server
+
+.PHONY: clean
+clean:
+ rm -f client/cmd/client
+ rm -f server/cmd/server
+
+up-server: gen-keys
+ echo -n "GOPHKEEPER_SERVICE_TOKEN_SECRET_KEY=" > server/build/dev_secret_key.env
+ openssl rand -hex 20 >> server/build/dev_secret_key.env
+ docker compose -f "server/build/docker-compose.yml" up -d --build
+
+down-server:
+ docker compose -f "server/build/docker-compose.yml" down -v
+
+start-server:
+ docker compose -f "server/build/docker-compose.yml" start
+
+stop-server:
+ docker compose -f "server/build/docker-compose.yml" stop
+
+run-client:
+ cd ./client/cmd/ && go build -tags=debug -o client
+ cp ./../config/dev.yaml ./config.yaml
+ ./client
+
+.PHONY: lint
+lint:
+ golangci-lint run ./client/...
+ golangci-lint run ./server/...
+ golangci-lint run ./pkg/...
+ golangci-lint run ./common/...
+
+gen-keys:
+ go run /usr/local/go/src/crypto/tls/generate_cert.go -duration=168h -ca=true -host='localhost' $(date +"%b %d %H:%M:%S %Y")
+ cp cert.pem server/build/cert.pem
+ cp key.pem server/build/key.pem
+ cp cert.pem client/cmd/cert.pem
+ rm cert.pem && rm key.pem
+
+gen-grpc:
+ protoc --go_out=. --go_opt=paths=import --go-grpc_out=. --go-grpc_opt=paths=import common/api/keeper.proto
diff --git a/README.md b/README.md
index f380db8..d663754 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,26 @@
# Менеджер паролей GophKeeper
-GophKeeper представляет собой клиент-серверную систему, позволяющую пользователю надёжно и безопасно хранить логины, пароли, бинарные данные и прочую приватную информацию.
\ No newline at end of file
+[![logo](gophkeeper.png)](gophkeeper.png)
+
+GophKeeper представляет собой клиент-серверную систему, позволяющую пользователю надёжно и безопасно хранить приватную информацию.
+
+Типы хранимой информации:
+- пары логин/пароль;
+- произвольные текстовые данные;
+- произвольные бинарные данные;
+- данные банковских карт.
+
+Подробное описание схемы работы приложения, в т.ч. обмена и защиты данных можно найти [здесь](docs/sheme.md).
+
+[![asciicast](https://asciinema.org/a/602664.svg)](https://asciinema.org/a/602664?speed=2.5&t=0:01)
+
+[![asciicast](https://asciinema.org/a/602668.svg)](https://asciinema.org/a/602668?speed=3&t=0:01)
+
+# Запуск
+Для локального запуска следует использовать Docker и предложенный Makefile:
+- up-server:
+ - генерирует ключ и сертификат для TLS,
+ - генерирует случайный секретный ключ, используемый для подписи токенов,
+ - запускает redis, postgres, mailpit (для перехвата писем от сервера) и сервер GophKeeper;
+ - на порту 8025 размещает веб-интерфейс mailpit.
+- run-client: создает и запускает клиент GophKeeper в папке client/cmd/.
\ No newline at end of file
diff --git a/client/cmd/.gitignore b/client/cmd/.gitignore
new file mode 100644
index 0000000..072e53a
--- /dev/null
+++ b/client/cmd/.gitignore
@@ -0,0 +1,3 @@
+*.db
+*.log
+*.yaml
\ No newline at end of file
diff --git a/client/cmd/builder.go b/client/cmd/builder.go
new file mode 100644
index 0000000..c8f2d7c
--- /dev/null
+++ b/client/cmd/builder.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+ "log/slog"
+
+ "github.com/ilyakaznacheev/cleanenv"
+ "gopkg.in/natefinch/lumberjack.v2"
+
+ "github.com/Karzoug/goph_keeper/client/internal/config"
+)
+
+var (
+ logFilename = "log.log"
+ configFilename = "config.yaml"
+)
+
+func buildConfig() (*config.Config, error) {
+ cfg := new(config.Config)
+
+ err := cleanenv.ReadConfig(configFilename, cfg)
+ if err != nil {
+ return nil, err
+ }
+
+ cfg.Env = envMode
+ cfg.Version = buildVersion
+
+ return cfg, nil
+}
+
+func buildLogger(env config.EnvType) (*slog.Logger, error) {
+ var log *slog.Logger
+
+ w := &lumberjack.Logger{
+ Filename: logFilename,
+ MaxSize: 5, // megabytes
+ MaxBackups: 3,
+ MaxAge: 28, //days
+ }
+
+ switch env {
+ case config.EnvDevelopment:
+ log = slog.New(
+ slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelDebug}),
+ )
+ default:
+ log = slog.New(
+ slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo}),
+ )
+ }
+
+ return log, nil
+}
diff --git a/client/cmd/debug.go b/client/cmd/debug.go
new file mode 100644
index 0000000..66fba6b
--- /dev/null
+++ b/client/cmd/debug.go
@@ -0,0 +1,9 @@
+//go:build debug
+
+package main
+
+import "github.com/Karzoug/goph_keeper/client/internal/config"
+
+func init() {
+ envMode = config.EnvDevelopment
+}
diff --git a/client/cmd/main.go b/client/cmd/main.go
new file mode 100644
index 0000000..cc05d02
--- /dev/null
+++ b/client/cmd/main.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "context"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "log/slog"
+
+ "golang.org/x/sync/errgroup"
+
+ "github.com/Karzoug/goph_keeper/client/internal/client"
+ "github.com/Karzoug/goph_keeper/client/internal/config"
+ "github.com/Karzoug/goph_keeper/client/internal/view"
+ "github.com/Karzoug/goph_keeper/pkg/logger/slog/sl"
+)
+
+var (
+ buildVersion = "N/A"
+ buildDate = "N/A"
+
+ envMode = config.EnvProduction
+)
+
+func main() {
+ cfg, err := buildConfig()
+ if err != nil {
+ log.Fatal("parse config error:\n", err)
+ }
+
+ logger, err := buildLogger(envMode)
+ if err != nil {
+ log.Fatal("build logger error:\n", err)
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
+ defer stop()
+
+ c, err := client.New(ctx, cfg, logger)
+ if err != nil {
+ logger.Error("client create", sl.Error(err))
+ os.Exit(1)
+ }
+
+ logger.Debug(
+ "starting goph-keeper client",
+ slog.String("env", envMode.String()),
+ slog.String("build version", buildVersion),
+ slog.String("build date", buildDate),
+ )
+
+ v, err := view.New(c)
+ if err != nil {
+ logger.Error("build ui", sl.Error(err))
+ os.Exit(1)
+ }
+
+ eg, ctx := errgroup.WithContext(ctx)
+
+ eg.Go(func() error {
+ return c.Run(ctx)
+ })
+ eg.Go(func() error {
+ return v.Run(ctx)
+ })
+
+ if err := eg.Wait(); err != nil {
+ logger.Error("application stopped with error", sl.Error(err))
+ os.Exit(1)
+ }
+}
diff --git a/client/config/config.yaml b/client/config/config.yaml
new file mode 100644
index 0000000..e76a320
--- /dev/null
+++ b/client/config/config.yaml
@@ -0,0 +1,13 @@
+# Your email, auth hash and server token (not password!)
+# can be stored in the following storage types:
+## All platforms: database
+## Windows: wincred
+## MacOS: keychain
+## Linux: pass, secret-service
+credentials_storage_type: "database"
+host: "localhost"
+port: "8080"
+# specify the filename if you want to use self-signed certificates
+cert_filename: ""
+# root path for file picker, empty value means user home directory
+root_path: ""
\ No newline at end of file
diff --git a/client/config/dev.yaml b/client/config/dev.yaml
new file mode 100644
index 0000000..5fe9497
--- /dev/null
+++ b/client/config/dev.yaml
@@ -0,0 +1,13 @@
+# Your email, auth hash and server token (not password!)
+# can be stored in the following storage types:
+## All platforms: database
+## Windows: wincred
+## MacOS: keychain
+## Linux: pass, secret-service
+credentials_storage_type: "database"
+host: "localhost"
+port: "8080"
+# specify the filename if you want to use self-signed certificates
+cert_filename: "cert.pem"
+# root path for file picker, empty value means user home directory
+root_path: ""
\ No newline at end of file
diff --git a/client/go.mod b/client/go.mod
index 3280f85..bebe332 100644
--- a/client/go.mod
+++ b/client/go.mod
@@ -1,3 +1,69 @@
module github.com/Karzoug/goph_keeper/client
-go 1.20
+go 1.21
+
+require (
+ github.com/Karzoug/goph_keeper/common v0.7.1
+ github.com/Karzoug/goph_keeper/pkg v0.4.0
+)
+
+require (
+ github.com/99designs/keyring v1.2.2
+ github.com/gdamore/tcell/v2 v2.6.0
+ github.com/matthewhartstonge/argon2 v0.3.3
+ github.com/rivo/tview v0.0.0-20230814110005-ccc2c8119703
+ github.com/rs/xid v1.5.0
+ github.com/stretchr/testify v1.8.1
+ golang.org/x/crypto v0.10.0
+ golang.org/x/sync v0.2.0
+ google.golang.org/grpc v1.57.0
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
+ modernc.org/sqlite v1.24.0
+)
+
+require (
+ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
+ github.com/BurntSushi/toml v1.2.1 // indirect
+ github.com/danieljoos/wincred v1.2.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
+ github.com/gdamore/encoding v1.0.0 // indirect
+ github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
+ github.com/golang-migrate/migrate/v4 v4.16.2
+ github.com/golang/protobuf v1.5.3 // indirect
+ github.com/google/uuid v1.3.0 // indirect
+ github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/ilyakaznacheev/cleanenv v1.5.0
+ github.com/joho/godotenv v1.5.1 // indirect
+ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.18 // indirect
+ github.com/mattn/go-runewidth v0.0.15 // indirect
+ github.com/mtibben/percent v0.2.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/rivo/uniseg v0.4.4 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ golang.org/x/mod v0.11.0 // indirect
+ golang.org/x/net v0.10.0 // indirect
+ golang.org/x/sys v0.11.0 // indirect
+ golang.org/x/term v0.9.0 // indirect
+ golang.org/x/text v0.10.0 // indirect
+ golang.org/x/tools v0.9.1 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20230814215434-ca7cfce7776a // indirect
+ google.golang.org/protobuf v1.31.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ lukechampine.com/uint128 v1.2.0 // indirect
+ modernc.org/cc/v3 v3.40.0 // indirect
+ modernc.org/ccgo/v3 v3.16.13 // indirect
+ modernc.org/libc v1.22.5 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.7.0 // indirect
+ modernc.org/opt v0.1.3 // indirect
+ modernc.org/strutil v1.2.0 // indirect
+ modernc.org/token v1.0.1 // indirect
+ olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
+)
diff --git a/client/go.sum b/client/go.sum
new file mode 100644
index 0000000..9dc9e65
--- /dev/null
+++ b/client/go.sum
@@ -0,0 +1,182 @@
+github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
+github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
+github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
+github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
+github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
+github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/Karzoug/goph_keeper/common v0.7.1 h1:lIM93VgdvjwfYuq65j45YPY/aeYkXALkmXrsIyElprE=
+github.com/Karzoug/goph_keeper/common v0.7.1/go.mod h1:LkSZ9pS4W6GdXG8ARiFkCgzugvJbfoLNdvdrThzUKII=
+github.com/Karzoug/goph_keeper/pkg v0.4.0 h1:ZQnMv8gLTfL3hxmxVc/gUt3ZT69oYGj4p3QMfG+d+Q8=
+github.com/Karzoug/goph_keeper/pkg v0.4.0/go.mod h1:DXUAGvjNBudmXwmAYrmEfqB5sULFI4/vRBmr4BZoZzg=
+github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
+github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
+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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
+github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
+github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
+github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg=
+github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y=
+github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
+github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
+github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=
+github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/go-cmp v0.5.5/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/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
+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/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
+github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
+github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
+github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/matthewhartstonge/argon2 v0.3.3 h1:38/hupgfzqO2UGxqXqmSqErE8KJvQnIxWWg7IXUqWgQ=
+github.com/matthewhartstonge/argon2 v0.3.3/go.mod h1:W2fhVs3+4FGxqDiap9SxxwNF/0SOVYcITpqDZe8RrhY=
+github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
+github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
+github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rivo/tview v0.0.0-20230814110005-ccc2c8119703 h1:ZyM/+FYnpbZsFWuCohniM56kRoHRB4r5EuIzXEYkpxo=
+github.com/rivo/tview v0.0.0-20230814110005-ccc2c8119703/go.mod h1:nVwGv4MP47T0jvlk7KuTTjjuSmrGO4JF0iaiNt4bufE=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
+github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
+github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
+github.com/stretchr/objx v0.1.0/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 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+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 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
+golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
+golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
+golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=
+golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
+golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
+golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230814215434-ca7cfce7776a h1:5rTPHLf5eLPfqGvw3fLpEmUpko2Ky91ft14LxGs5BZc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230814215434-ca7cfce7776a/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M=
+google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
+google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
+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.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
+gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
+lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
+modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
+modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
+modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
+modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
+modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
+modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
+modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
+modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
+modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.7.0 h1:2pXdbgdP5hIyDp2JqIwkHNZ1sAjEbh8GnRpcqFWBf7E=
+modernc.org/memory v1.7.0/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI=
+modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
+modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
+modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
+modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
+modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
+olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
+olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=
diff --git a/client/internal/client/client.go b/client/internal/client/client.go
new file mode 100644
index 0000000..f39ee1f
--- /dev/null
+++ b/client/internal/client/client.go
@@ -0,0 +1,173 @@
+package client
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "io"
+ "os"
+ "time"
+
+ "log/slog"
+
+ "google.golang.org/grpc"
+ gcreds "google.golang.org/grpc/credentials"
+
+ "github.com/Karzoug/goph_keeper/client/internal/config"
+ "github.com/Karzoug/goph_keeper/client/internal/model"
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ "github.com/Karzoug/goph_keeper/client/internal/repository/storage"
+ "github.com/Karzoug/goph_keeper/client/internal/repository/storage/native"
+ sqlite "github.com/Karzoug/goph_keeper/client/internal/repository/storage/sqllite"
+ pb "github.com/Karzoug/goph_keeper/common/grpc"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+const (
+ createClientTimeout = 5 * time.Second
+ syncTimeout = 5 * time.Second
+ syncInterval = 5 * time.Minute
+)
+
+type clientCredentialsStorage interface {
+ // SetCredentials adds or updates email, token and encryption key.
+ SetCredentials(context.Context, model.Credentials) error
+ //GetCredentials returns email and encryption key, or an error if they are not found.
+ // It can also return a token if it exists.
+ GetCredentials(context.Context) (model.Credentials, error)
+ // DeleteCredentials deletes all credentials: email, token and encryption key.
+ DeleteCredentials(context.Context) error
+}
+
+type clientStorage interface {
+ GetOwner(ctx context.Context) (string, error)
+ SetOwner(ctx context.Context, email string) error
+ ClearVault(ctx context.Context) error
+
+ ListVaultItems(context.Context) ([]vault.Item, error)
+ ListVaultItemsIDName(context.Context) ([]vault.IDName, error)
+ ListModifiedVaultItems(context.Context) ([]vault.Item, error)
+ GetVaultItem(ctx context.Context, id string) (vault.Item, error)
+ SetVaultItem(ctx context.Context, item vault.Item) error
+ DeleteVaultItem(ctx context.Context, id string) error
+ MoveVaultItemToConflict(ctx context.Context, id string) error
+ GetLastServerUpdatedAt(ctx context.Context) (int64, error)
+
+ Close() error
+}
+
+type Client struct {
+ cfg *config.Config
+ logger *slog.Logger
+
+ storage clientStorage
+ credentialsStorage clientCredentialsStorage
+ credentials credentials
+ conn *grpc.ClientConn
+ grpcClient pb.GophKeeperServiceClient
+}
+
+func New(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*Client, error) {
+ const op = "create client"
+
+ ctx, cancel := context.WithTimeout(ctx, createClientTimeout)
+ defer cancel()
+
+ c := &Client{
+ cfg: cfg,
+ logger: logger.With(slog.String("from", "client")),
+ }
+
+ ss, err := sqlite.New(ctx)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ c.storage = ss
+
+ c.credentialsStorage = ss
+ if cfg.CredentialsStorageType != storage.Database {
+ ns, err := native.New(cfg.CredentialsStorageType)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ c.credentialsStorage = ns
+ }
+
+ if err := c.restoreCredentials(ctx); err != nil {
+ c.logger.Error(op, err)
+ }
+
+ cs, err := loadTLSCredentials(cfg.Host, cfg.CertFilename)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ addr := cfg.Host + ":" + cfg.Port
+ c.conn, err = grpc.DialContext(ctx, addr, grpc.WithTransportCredentials(cs))
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ c.grpcClient = pb.NewGophKeeperServiceClient(c.conn)
+
+ return c, nil
+}
+
+func (c *Client) Run(ctx context.Context) error {
+ const op = "client: run"
+
+ ticker := time.NewTicker(syncInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return e.Wrap(op, c.storage.Close())
+ case <-ticker.C:
+ func() {
+ syncCtx, cancel := context.WithTimeout(ctx, syncTimeout)
+ defer cancel()
+ _ = c.SyncVaultItems(syncCtx)
+ }()
+ }
+ }
+}
+
+func (c *Client) Version() string {
+ return c.cfg.Version
+}
+
+func (c *Client) RootPath() string {
+ return c.cfg.RootPath
+}
+
+func loadTLSCredentials(host, certFilename string) (gcreds.TransportCredentials, error) {
+ const op = "load TLS credentials"
+
+ config := &tls.Config{
+ ServerName: host,
+ MinVersion: tls.VersionTLS13,
+ }
+
+ if len(certFilename) == 0 {
+ return gcreds.NewTLS(config), nil
+ }
+
+ certPool := x509.NewCertPool()
+ f, err := os.Open(certFilename)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ defer f.Close()
+
+ b, err := io.ReadAll(f)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ if !certPool.AppendCertsFromPEM(b) {
+ return nil, e.Wrap(op, err)
+ }
+ config.RootCAs = certPool
+
+ return gcreds.NewTLS(config), nil
+}
diff --git a/client/internal/client/credential.go b/client/internal/client/credential.go
new file mode 100644
index 0000000..8b3df69
--- /dev/null
+++ b/client/internal/client/credential.go
@@ -0,0 +1,184 @@
+package client
+
+import (
+ "context"
+ "errors"
+
+ "github.com/Karzoug/goph_keeper/client/internal/model"
+ "github.com/Karzoug/goph_keeper/client/internal/model/auth"
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ "github.com/Karzoug/goph_keeper/client/internal/repository/storage"
+ "github.com/Karzoug/goph_keeper/client/pkg/crypto"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+type credentials struct {
+ model.Credentials
+ AuthHash auth.Hash
+}
+
+// HasLocalCredintials indicates whether credentials for the application to work locally.
+func (c *Client) HasLocalCredintials() bool {
+ return len(c.credentials.Email) > 0 && c.credentials.EncrKey.Hash != nil
+}
+
+// HasToken indicates whether token for the application to work online.
+func (c *Client) HasToken() bool {
+ return len(c.credentials.Token) > 0
+}
+
+// buildPasswordHashes builds auth hash and encryption key from given email and password.
+//
+// Warning(!): wipes given password slice to prevent long-term storage in memory.
+func buildPasswordHashes(ctx context.Context, email string, password []byte) (auth.Hash, vault.EncryptionKey, error) {
+ const op = "build password hashes"
+
+ defer crypto.Wipe(password) // prevent long-term storage of the password in memory
+
+ hash, err := auth.NewHash([]byte(email), password)
+ if err != nil {
+ return nil, vault.EncryptionKey{}, e.Wrap(op, err)
+ }
+
+ encrKey, err := vault.NewEncryptionKey([]byte(email), password)
+ if err != nil {
+ return nil, vault.EncryptionKey{}, e.Wrap(op, err)
+ }
+
+ return hash, encrKey, nil
+}
+
+// setCredentialsForOwnerOnly sets credentials if only the local vault (storage) owner email
+// is equal the given email.
+func (c *Client) setCredentialsForOwnerOnly(ctx context.Context, email string, hash auth.Hash, encrKey vault.EncryptionKey) error {
+ const op = "set credentials for owner only"
+
+ owner, err := c.storage.GetOwner(ctx)
+ if err != nil {
+ if !errors.Is(err, storage.ErrRecordNotFound) {
+ return e.Wrap(op, err)
+ }
+
+ // case: owner db is not set
+ err := c.storage.ClearVault(ctx)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ err = c.storage.SetOwner(ctx, email)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ owner = email
+ }
+
+ if owner != email {
+ return ErrUserNeedAuthentication
+ }
+
+ c.credentials = credentials{
+ Credentials: model.Credentials{
+ Email: email,
+ EncrKey: encrKey,
+ },
+ AuthHash: hash,
+ }
+
+ if err := c.credentialsStorage.SetCredentials(ctx, c.credentials.Credentials); err != nil {
+ return e.Wrap(op, err)
+ }
+
+ return nil
+}
+
+// setCredentialsForced sets credentials.
+//
+// Warning(!): if the local vault (storage) owner email is not equal the given email
+// method clear all data in storage.
+func (c *Client) setCredentialsForced(ctx context.Context, email string, hash auth.Hash, encrKey vault.EncryptionKey) error {
+ const op = "set credentials"
+
+ owner, err := c.storage.GetOwner(ctx)
+ if err != nil {
+ if !errors.Is(err, storage.ErrRecordNotFound) {
+ return e.Wrap(op, err)
+ }
+ owner = ""
+ }
+
+ if owner != email {
+ err := c.storage.ClearVault(ctx)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ err = c.storage.SetOwner(ctx, email)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ }
+
+ c.credentials = credentials{
+ Credentials: model.Credentials{
+ Email: email,
+ EncrKey: encrKey,
+ },
+ AuthHash: hash,
+ }
+
+ if err := c.credentialsStorage.SetCredentials(ctx, c.credentials.Credentials); err != nil {
+ return e.Wrap(op, err)
+ }
+
+ return nil
+}
+
+func (c *Client) setToken(ctx context.Context, token string) error {
+ const op = "set token"
+
+ c.credentials.AuthHash = nil
+ c.credentials.Token = token
+
+ if !c.HasLocalCredintials() {
+ return e.Wrap(op, ErrUserNeedAuthentication)
+ }
+
+ return e.Wrap(op, c.credentialsStorage.SetCredentials(ctx, c.credentials.Credentials))
+}
+
+func (c *Client) clearToken(ctx context.Context) error {
+ const op = "clear token"
+
+ c.credentials.Token = ""
+
+ return e.Wrap(op,
+ c.credentialsStorage.SetCredentials(ctx, c.credentials.Credentials))
+}
+
+func (c *Client) clearCredentials(ctx context.Context) error {
+ const op = "clear credentials"
+
+ c.credentials = credentials{}
+
+ return e.Wrap(op,
+ c.credentialsStorage.DeleteCredentials(ctx))
+}
+
+func (c *Client) restoreCredentials(ctx context.Context) error {
+ const op = "restore credentials"
+
+ creds, err := c.credentialsStorage.GetCredentials(ctx)
+ if err != nil {
+ if errors.Is(err, storage.ErrRecordNotFound) {
+ return nil
+ }
+ return e.Wrap(op, err)
+ }
+ // encrKey, err := vault.EncryptionKeyFromString(encrKeyString)
+ // if err != nil {
+ // return e.Wrap(op, err)
+ // }
+
+ c.credentials = credentials{
+ Credentials: creds,
+ }
+ return nil
+}
diff --git a/client/internal/client/error.go b/client/internal/client/error.go
new file mode 100644
index 0000000..e49f7ff
--- /dev/null
+++ b/client/internal/client/error.go
@@ -0,0 +1,21 @@
+package client
+
+import (
+ "errors"
+ "fmt"
+)
+
+var (
+ ErrPasswordTooShort = fmt.Errorf("password too short (must be at least %d characters)", MinPasswordLength)
+ ErrInvalidEmail = errors.New("invalid email")
+ ErrUserAlreadyExists = errors.New("user already exists")
+ ErrUserInvalidPassword = errors.New("invalid password")
+ ErrUserEmailNotVerified = errors.New("email not verified")
+ ErrInvalidEmailVerificationCode = errors.New("invalid email verification code")
+ ErrUserNotExists = errors.New("user not exists")
+ ErrAppInternal = errors.New("app internal error")
+ ErrServerInternal = errors.New("server internal error")
+ ErrServerUnavailable = errors.New("no connection to server")
+ ErrUserNeedAuthentication = errors.New("need authentication: please login")
+ ErrConflictVersion = errors.New("conflict data version on server and client")
+)
diff --git a/client/internal/client/sync.go b/client/internal/client/sync.go
new file mode 100644
index 0000000..bce0943
--- /dev/null
+++ b/client/internal/client/sync.go
@@ -0,0 +1,210 @@
+package client
+
+import (
+ "context"
+ "errors"
+ "sort"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ "github.com/Karzoug/goph_keeper/client/internal/repository/storage"
+ pb "github.com/Karzoug/goph_keeper/common/grpc"
+ cvault "github.com/Karzoug/goph_keeper/common/model/vault"
+ "github.com/Karzoug/goph_keeper/pkg/logger/slog/sl"
+)
+
+// SyncVaultItems synchronizes client and server vault data.
+func (c *Client) SyncVaultItems(ctx context.Context) error {
+ if !c.HasLocalCredintials() {
+ return ErrUserNeedAuthentication
+ }
+ ctx, err := c.newContextWithAuthData(ctx)
+ if err != nil {
+ return ErrUserNeedAuthentication
+ }
+
+ if err := c.updateVaultItemsFromServer(ctx); err != nil {
+ return err
+ }
+ if err := c.sendModifiedVaultItemsToServer(ctx); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (c *Client) updateVaultItemsFromServer(ctx context.Context) error {
+ const op = "update vault items from server"
+
+ // looking for the time of the last entry received from the server
+ since, err := c.storage.GetLastServerUpdatedAt(ctx)
+ if err != nil {
+ if !errors.Is(err, storage.ErrRecordNotFound) {
+ c.logger.Debug(op, err)
+ }
+ since = 0
+ }
+
+ // ask the server if there have been updates since then
+ resp, err := c.grpcClient.ListVaultItems(ctx, &pb.ListVaultItemsRequest{
+ Since: since,
+ })
+ if err != nil {
+ switch {
+ case errors.Is(err, pb.ErrEmptyAuthData),
+ errors.Is(err, pb.ErrInvalidTokenFormat),
+ errors.Is(err, pb.ErrUserNeedAuthentication):
+ c.logger.Debug(op, sl.Error(err))
+ _ = c.clearToken(ctx)
+ return ErrUserNeedAuthentication
+ default:
+ c.logger.Debug(op, err)
+ if status.Code(err) == codes.Unavailable {
+ return ErrServerUnavailable
+ }
+ return ErrServerInternal
+ }
+ }
+
+ // process items in chronological order -
+ // this will allow us to return to the process later in case of an error and not get conflicts
+ sort.Slice(resp.Items, func(i, j int) bool {
+ return resp.Items[i].ServerUpdatedAt < resp.Items[j].ServerUpdatedAt
+ })
+ for i := 0; i < len(resp.Items); i++ {
+ item := vault.Item{
+ ID: resp.Items[i].Id,
+ Name: resp.Items[i].Name,
+ Type: cvault.ItemType(resp.Items[i].Itype),
+ Value: resp.Items[i].Value,
+ ServerUpdatedAt: resp.Items[i].ServerUpdatedAt,
+ ClientUpdatedAt: resp.Items[i].ServerUpdatedAt,
+ IsDeleted: resp.Items[i].IsDeleted,
+ }
+ dbItem, err := c.storage.GetVaultItem(ctx, item.ID)
+ if err != nil {
+ if !errors.Is(err, storage.ErrRecordNotFound) {
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+ }
+ // case: conflict version on server and client,
+ // move client item version to conflict db table and
+ // save server item version to main vault table
+ if !(dbItem.ServerUpdatedAt == dbItem.ClientUpdatedAt) &&
+ item.ServerUpdatedAt > dbItem.ServerUpdatedAt {
+ err := c.storage.MoveVaultItemToConflict(ctx, item.ID)
+ if err != nil {
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+ }
+ err = c.storage.SetVaultItem(ctx, item)
+ if err != nil {
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+ }
+
+ return nil
+}
+
+func (c *Client) sendModifiedVaultItemsToServer(ctx context.Context) error {
+ const op = "send modified vault items to server"
+
+ // get all modified items from storage
+ modifiedItems, err := c.storage.ListModifiedVaultItems(ctx)
+ if err != nil {
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+
+ // process items in chronological order -
+ // this will allow us to return to the process later in case of an error and not get conflicts
+ sort.Slice(modifiedItems, func(i, j int) bool {
+ return modifiedItems[i].ServerUpdatedAt < modifiedItems[j].ServerUpdatedAt
+ })
+ for i := 0; i < len(modifiedItems); i++ {
+ var (
+ serverTime int64
+ err error
+ )
+ if modifiedItems[i].Type == cvault.BinaryLarge {
+ serverTime, err = c.sendLargeVaultItem(ctx, modifiedItems[i])
+ } else {
+ serverTime, err = c.sendVaultItem(ctx, modifiedItems[i])
+ }
+ if err != nil {
+ switch {
+ case errors.Is(err, ErrConflictVersion):
+ // usually this is not happened,
+ // but if so, we need to exit here,
+ // next method iteration hadle this conflict
+ return nil
+ case errors.Is(err, ErrUserNeedAuthentication):
+ _ = c.clearToken(ctx)
+ return nil
+ }
+ return err
+ }
+
+ // if synchronization for this item was successful,
+ // update item server time
+ modifiedItems[i].ServerUpdatedAt = serverTime
+ if err := c.storage.SetVaultItem(ctx, modifiedItems[i]); err != nil {
+ c.logger.Debug(op, err)
+ return ErrServerInternal
+ }
+ }
+ return nil
+}
+
+func (c *Client) sendLargeVaultItem(ctx context.Context, item vault.Item) (int64, error) {
+ // TODO: implement me
+ panic("not implemented")
+}
+
+func (c *Client) sendVaultItem(ctx context.Context, item vault.Item) (int64, error) {
+ const op = "send modified small vault item to server"
+
+ resp, err := c.grpcClient.SetVaultItem(ctx, &pb.SetVaultItemRequest{
+ Item: &pb.VaultItem{
+ Id: item.ID,
+ Name: item.Name,
+ Itype: pb.IType(item.Type),
+ Value: item.Value,
+ ServerUpdatedAt: item.ServerUpdatedAt,
+ IsDeleted: item.IsDeleted,
+ },
+ })
+ if err != nil {
+ switch {
+ case errors.Is(err, pb.ErrEmptyAuthData),
+ errors.Is(err, pb.ErrEmptyAuthData),
+ errors.Is(err, pb.ErrInvalidTokenFormat),
+ errors.Is(err, pb.ErrUserNeedAuthentication):
+ return 0, ErrUserNeedAuthentication
+ case errors.Is(err, pb.ErrVaultItemConflictVersion):
+ return 0, ErrConflictVersion
+ default:
+ c.logger.Debug(op, err)
+ if status.Code(err) == codes.Unavailable {
+ return 0, ErrServerUnavailable
+ }
+ return 0, ErrServerInternal
+ }
+ }
+
+ return resp.ServerUpdatedAt, nil
+}
+
+func (c *Client) newContextWithAuthData(ctx context.Context) (context.Context, error) {
+ if !c.HasToken() {
+ return ctx, pb.ErrEmptyAuthData
+ }
+ md := metadata.New(map[string]string{"token": c.credentials.Token})
+ return metadata.NewOutgoingContext(ctx, md), nil
+}
diff --git a/client/internal/client/user.go b/client/internal/client/user.go
new file mode 100644
index 0000000..0150894
--- /dev/null
+++ b/client/internal/client/user.go
@@ -0,0 +1,205 @@
+package client
+
+import (
+ "context"
+ "errors"
+ "net/mail"
+ "unicode/utf8"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+
+ "github.com/Karzoug/goph_keeper/client/internal/model/auth"
+ "github.com/Karzoug/goph_keeper/client/pkg/crypto"
+ pb "github.com/Karzoug/goph_keeper/common/grpc"
+)
+
+const MinPasswordLength = 8
+
+// Register registers a new user on the server with the gieven email and password.
+// Method returns an error if the email or password is not valid.
+//
+// Warning(!): method wipes the given password slice to prevent long-term storage in memory.
+func (c *Client) Register(ctx context.Context, email string, password []byte) error {
+ const op = "register user"
+
+ defer crypto.Wipe(password) // prevent long-term storage of the password in memory
+
+ if !isValidEmail(email) {
+ return ErrInvalidEmail
+ }
+
+ if utf8.RuneCount(password) < MinPasswordLength {
+ return ErrPasswordTooShort
+ }
+
+ hash, err := auth.NewHash([]byte(email), password)
+ if err != nil {
+ if errors.Is(err, auth.ErrEmptyPassword) {
+ return ErrPasswordTooShort
+ }
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+
+ _, err = c.grpcClient.Register(ctx, &pb.RegisterRequest{
+ Email: email,
+ Hash: hash,
+ })
+ if err != nil {
+ switch {
+ case errors.Is(err, pb.ErrInvalidEmailFormat):
+ return ErrInvalidEmail
+ case errors.Is(err, pb.ErrUserAlreadyExists):
+ return ErrUserAlreadyExists
+ default:
+ c.logger.Debug(op, err)
+ if status.Code(err) == codes.Unavailable {
+ return ErrServerUnavailable
+ }
+ return ErrServerInternal
+ }
+ }
+
+ return nil
+}
+
+// Login builds local credentials. Then connects to the server:
+//
+// 1. connection error: if local vault owner email is equal to the given email,
+// saves the local credentials, application works offline,
+// otherwise it returns ErrServerInternal;
+//
+// 2. on server authentication failure:
+// does not save data, returns ErrUserNeedAuthentication;
+//
+// 3. case of unverified mail: returns ErrUserEmailNotVerified;
+//
+// 4. on success: saves the data and the received token,
+// if local vault owner email is not equal to the given email,
+// then the local vault will be cleared.
+func (c *Client) Login(ctx context.Context, email string, password []byte) error {
+ const op = "login user"
+
+ if !isValidEmail(email) {
+ return ErrInvalidEmail
+ }
+
+ if utf8.RuneCount(password) < MinPasswordLength {
+ return ErrPasswordTooShort
+ }
+
+ hash, encrKey, err := buildPasswordHashes(ctx, email, password)
+ if err != nil {
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+
+ resp, err := c.grpcClient.Login(ctx, &pb.LoginRequest{
+ Email: email,
+ Hash: []byte(hash),
+ })
+ if err != nil {
+ switch {
+ case errors.Is(err, pb.ErrUserInvalidHash):
+ _ = c.clearCredentials(ctx)
+ return ErrUserInvalidPassword
+ case errors.Is(err, pb.ErrUserEmailNotVerified):
+ if err := c.setCredentialsForced(ctx, email, hash, encrKey); err != nil {
+ c.logger.Error(op, err)
+ return ErrAppInternal
+ }
+ return ErrUserEmailNotVerified
+ case errors.Is(err, pb.ErrUserNotExists):
+ _ = c.clearCredentials(ctx)
+ return ErrUserNotExists
+ default:
+ // problems with grpc,
+ // but if this is the owner of the vault, then they can try to work offline
+ if err := c.setCredentialsForOwnerOnly(ctx, email, hash, encrKey); err != nil {
+ if errors.Is(err, ErrUserNeedAuthentication) {
+ c.logger.Debug(op, err)
+ return ErrUserNeedAuthentication
+ } else {
+ c.logger.Error(op, err)
+ }
+ return ErrAppInternal
+ }
+ c.logger.Debug(op, err)
+ if status.Code(err) == codes.Unavailable {
+ return ErrServerUnavailable
+ }
+ return nil
+ }
+ }
+
+ if err := c.setCredentialsForced(ctx, email, hash, encrKey); err != nil {
+ c.logger.Error(op, err)
+ return ErrAppInternal
+ }
+ if err := c.setToken(ctx, resp.Token); err != nil {
+ c.logger.Error(op, err)
+ return ErrAppInternal
+ }
+ return nil
+}
+
+// VerifyEmail sends a verification code to the server and
+// returns nil only if successful.
+func (c *Client) VerifyEmail(ctx context.Context, code string) error {
+ const op = "verify email"
+
+ if len(c.credentials.Email) == 0 ||
+ c.credentials.AuthHash == nil {
+ return ErrUserNeedAuthentication
+ }
+
+ resp, err := c.grpcClient.Login(ctx, &pb.LoginRequest{
+ Email: c.credentials.Email,
+ Hash: c.credentials.AuthHash,
+ EmailCode: code,
+ })
+ if err != nil {
+ switch {
+ case errors.Is(err, pb.ErrUserInvalidHash):
+ _ = c.clearCredentials(ctx)
+ return ErrUserInvalidPassword
+ case errors.Is(err, pb.ErrUserEmailNotVerified):
+ return ErrInvalidEmailVerificationCode
+ case errors.Is(err, pb.ErrUserNotExists):
+ _ = c.clearCredentials(ctx)
+ return ErrUserNotExists
+ default:
+ c.logger.Debug(op, err)
+ if status.Code(err) == codes.Unavailable {
+ return ErrServerUnavailable
+ }
+ return ErrServerInternal
+ }
+ }
+
+ if err := c.setToken(ctx, resp.Token); err != nil {
+ c.logger.Error(op, err)
+ return ErrAppInternal
+ }
+ return nil
+}
+
+func (c *Client) Logout(ctx context.Context) error {
+ const op = "logout user"
+
+ if err := c.clearCredentials(ctx); err != nil {
+ c.logger.Error(op, err)
+ return ErrAppInternal
+ }
+
+ return nil
+}
+
+func isValidEmail(email string) bool {
+ if len(email) == 0 {
+ return false
+ }
+ _, err := mail.ParseAddress(email)
+ return err == nil
+}
diff --git a/client/internal/client/vault.go b/client/internal/client/vault.go
new file mode 100644
index 0000000..031eb91
--- /dev/null
+++ b/client/internal/client/vault.go
@@ -0,0 +1,145 @@
+package client
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/rs/xid"
+
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ "github.com/Karzoug/goph_keeper/client/internal/repository/storage"
+)
+
+func (c *Client) ListVaultItemsIDName(ctx context.Context) ([]vault.IDName, error) {
+ const op = "list vault items names"
+
+ if !c.HasLocalCredintials() {
+ return nil, ErrUserNeedAuthentication
+ }
+
+ names, err := c.storage.ListVaultItemsIDName(ctx)
+ if err != nil {
+ c.logger.Debug(op, err)
+ return nil, ErrAppInternal
+ }
+
+ return names, nil
+}
+
+func (c *Client) DeleteVaultItem(ctx context.Context, id string) error {
+ const op = "delete vault item"
+
+ if !c.HasLocalCredintials() {
+ return ErrUserNeedAuthentication
+ }
+
+ item, err := c.storage.GetVaultItem(ctx, id)
+ if err != nil {
+ if errors.Is(err, storage.ErrRecordNotFound) {
+ return nil
+ }
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+
+ // case: the data was only on the client,
+ // so there is no need to synchronize it with the server,
+ // just delete it
+ if item.ServerUpdatedAt == 0 {
+ if err := c.storage.DeleteVaultItem(ctx, id); err != nil {
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+ return nil
+ }
+
+ item.Name = ""
+ item.Value = nil
+ item.IsDeleted = true
+ item.ClientUpdatedAt = time.Now().UnixMicro()
+
+ if err := c.storage.SetVaultItem(ctx, item); err != nil {
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+
+ t, err := c.sendVaultItem(ctx, item)
+ if err != nil {
+ c.logger.Debug(op, err)
+ return err
+ }
+
+ item.ServerUpdatedAt = t
+ if err := c.storage.SetVaultItem(ctx, item); err != nil {
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+
+ return nil
+}
+
+func (c *Client) EncryptAndSetVaultItem(ctx context.Context, item vault.Item, value any) error {
+ const op = "client: encrypt and set vault item"
+
+ if !c.HasLocalCredintials() {
+ return ErrUserNeedAuthentication
+ }
+
+ if len(item.ID) == 0 {
+ item.ID = xid.New().String()
+ }
+ item.ClientUpdatedAt = time.Now().UnixMicro()
+
+ if err := item.EncryptAndSetValue(value, c.credentials.EncrKey); err != nil {
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+
+ if err := c.storage.SetVaultItem(ctx, item); err != nil {
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+
+ ctx, err := c.newContextWithAuthData(ctx)
+ if err != nil {
+ return nil
+ }
+
+ t, err := c.sendVaultItem(ctx, item)
+ if err != nil {
+ c.logger.Debug(op, err)
+ return err
+ }
+
+ item.ServerUpdatedAt = t
+ if err := c.storage.SetVaultItem(ctx, item); err != nil {
+ c.logger.Debug(op, err)
+ return ErrAppInternal
+ }
+
+ return nil
+}
+
+func (c *Client) DecryptAndGetVaultItem(ctx context.Context, id string) (vault.Item, any, error) {
+ const op = "client: decrypt and get vault item"
+
+ if !c.HasLocalCredintials() {
+ return vault.Item{}, nil, ErrUserNeedAuthentication
+ }
+
+ item, err := c.storage.GetVaultItem(ctx, id)
+ if err != nil {
+ c.logger.Debug(op, err)
+ return vault.Item{}, nil, ErrAppInternal
+ }
+
+ value, err := item.DecryptAnGetValue(c.credentials.EncrKey)
+ if err != nil {
+ c.logger.Debug(op, err)
+ return vault.Item{}, nil, ErrAppInternal
+ }
+ item.Value = nil
+
+ return item, value, nil
+}
diff --git a/client/internal/config/config.go b/client/internal/config/config.go
new file mode 100644
index 0000000..c1a2dbf
--- /dev/null
+++ b/client/internal/config/config.go
@@ -0,0 +1,15 @@
+package config
+
+import "github.com/Karzoug/goph_keeper/client/internal/repository/storage"
+
+// Config is a configuration for goph-keeper client.
+type Config struct {
+ // Env is a environment type (production or development).
+ Env EnvType
+ CredentialsStorageType storage.Type `yaml:"credentials_storage_type" env:"GOPH_KEEPER_CREDENTIALS_STORAGE_TYPE" env-default:"database"`
+ Version string
+ Host string `yaml:"host" env:"GOPH_KEEPER_HOST" env-default:"localhost"`
+ Port string `yaml:"port" env:"GOPH_KEEPER_PORT" env-default:"8080"`
+ CertFilename string `yaml:"cert_filename" env:"GOPH_KEEPER_CERT_FILENAME"`
+ RootPath string `yaml:"root_path" env:"GOPH_KEEPER_ROOT_PATH"`
+}
diff --git a/client/internal/config/envType.go b/client/internal/config/envType.go
new file mode 100644
index 0000000..da02ca7
--- /dev/null
+++ b/client/internal/config/envType.go
@@ -0,0 +1,34 @@
+package config
+
+import "errors"
+
+const (
+ // EnvDevelopment is a constant defining the development environment.
+ EnvProduction EnvType = iota
+ // EnvProduction is a constant defining the production environment.
+ EnvDevelopment
+)
+
+const (
+ envProductionString = "production"
+ envDevelopmentString = "development"
+ envUnknownString = "unknown"
+)
+
+// ErrUnknownEnv is an error returned when the environment is unknown.
+var ErrUnknownEnv = errors.New("unknown environment mode")
+
+// EnvType is a type of environment (production or development).
+type EnvType int8
+
+// String returns the string representation of the environment type variable.
+func (e EnvType) String() string {
+ switch e {
+ case EnvDevelopment:
+ return envDevelopmentString
+ case EnvProduction:
+ return envProductionString
+ default:
+ return envUnknownString
+ }
+}
diff --git a/client/internal/model/auth/hash.go b/client/internal/model/auth/hash.go
new file mode 100644
index 0000000..761fb95
--- /dev/null
+++ b/client/internal/model/auth/hash.go
@@ -0,0 +1,34 @@
+package auth
+
+import (
+ "errors"
+
+ "github.com/matthewhartstonge/argon2"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+var (
+ ErrEmptyEmail = errors.New("empty email")
+ ErrEmptyPassword = errors.New("empty password")
+)
+
+type Hash []byte
+
+func NewHash(email, password []byte) (Hash, error) {
+ if len(password) == 0 {
+ return nil, ErrEmptyPassword
+ }
+ if len(email) == 0 {
+ return nil, ErrEmptyEmail
+ }
+ argon := argon2.DefaultConfig()
+ argon.TimeCost++ // Hash differs from EncryptionKey with an additional encryption step
+
+ encoded, err := argon.Hash(password, email)
+ if err != nil {
+ return nil, e.Wrap("model: create hash", err)
+ }
+
+ return Hash(encoded.Encode()), nil
+}
diff --git a/client/internal/model/credentials.go b/client/internal/model/credentials.go
new file mode 100644
index 0000000..0650a06
--- /dev/null
+++ b/client/internal/model/credentials.go
@@ -0,0 +1,11 @@
+package model
+
+import (
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+)
+
+type Credentials struct {
+ Email string
+ Token string
+ EncrKey vault.EncryptionKey
+}
diff --git a/client/internal/model/vault/encrKey.go b/client/internal/model/vault/encrKey.go
new file mode 100644
index 0000000..bf37074
--- /dev/null
+++ b/client/internal/model/vault/encrKey.go
@@ -0,0 +1,52 @@
+package vault
+
+import (
+ "errors"
+
+ "github.com/matthewhartstonge/argon2"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+var (
+ ErrEmptyEmail = errors.New("empty email")
+ ErrEmptyPassword = errors.New("empty password")
+)
+
+type EncryptionKey struct {
+ argon2.Raw
+}
+
+func NewEncryptionKey(email, password []byte) (EncryptionKey, error) {
+ const op = "create encryption key"
+
+ if len(password) == 0 {
+ return EncryptionKey{}, e.Wrap(op, ErrEmptyPassword)
+ }
+ if len(email) == 0 {
+ return EncryptionKey{}, e.Wrap(op, ErrEmptyEmail)
+ }
+ argon := argon2.DefaultConfig()
+
+ encoded, err := argon.Hash(password, email)
+ if err != nil {
+ return EncryptionKey{}, e.Wrap(op, err)
+ }
+
+ return EncryptionKey{Raw: encoded}, nil
+}
+
+func (k *EncryptionKey) UnmarshalBinary(data []byte) error {
+ const op = "encryption key from bytes"
+
+ raw, err := argon2.Decode(data)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ k.Raw = raw
+ return nil
+}
+
+func (k EncryptionKey) MarshalBinary() (data []byte, err error) {
+ return k.Raw.Encode(), nil
+}
diff --git a/client/internal/model/vault/item.go b/client/internal/model/vault/item.go
new file mode 100644
index 0000000..3bc5e45
--- /dev/null
+++ b/client/internal/model/vault/item.go
@@ -0,0 +1,78 @@
+package vault
+
+import (
+ "bytes"
+ "encoding/gob"
+ "errors"
+
+ "github.com/Karzoug/goph_keeper/client/pkg/crypto/chacha20poly1305"
+ cvault "github.com/Karzoug/goph_keeper/common/model/vault"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+var ErrUnknownVaultType = errors.New("unknown vault type")
+
+type Item cvault.Item
+
+func (item *Item) EncryptAndSetValue(data any, encrKey EncryptionKey) error {
+ const op = "vault: encrypt and set value"
+
+ br := bytes.NewBuffer(nil)
+ enc := gob.NewEncoder(br)
+ if err := enc.Encode(data); err != nil {
+ return e.Wrap(op, err)
+ }
+
+ bw := bytes.NewBuffer(nil)
+ bw.Grow(chacha20poly1305.GetCapacityForEncryptedValue(br.Len()))
+
+ if err := chacha20poly1305.Encrypt(br, bw, encrKey.Hash); err != nil {
+ return e.Wrap(op, err)
+ }
+
+ item.Value = bw.Bytes()
+
+ return nil
+}
+
+func (item Item) DecryptAnGetValue(encrKey EncryptionKey) (any, error) {
+ const op = "vault: decrypt and get value"
+
+ br := bytes.NewReader(item.Value)
+ bw := bytes.NewBuffer(nil)
+
+ if err := chacha20poly1305.Decrypt(br, bw, encrKey.Hash); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ dec := gob.NewDecoder(bw)
+
+ switch item.Type {
+ case cvault.Password:
+ psw := Password{}
+ if err := dec.Decode(&psw); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ return psw, nil
+ case cvault.Card:
+ crd := Card{}
+ if err := dec.Decode(&crd); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ return crd, nil
+ case cvault.Text:
+ txt := Text{}
+ if err := dec.Decode(&txt); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ return txt, nil
+ case cvault.Binary:
+ b := Binary{}
+ if err := dec.Decode(&b); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ return b, nil
+ default:
+ return nil, e.Wrap(op, ErrUnknownVaultType)
+ }
+}
diff --git a/client/internal/model/vault/types.go b/client/internal/model/vault/types.go
new file mode 100644
index 0000000..7a88c3d
--- /dev/null
+++ b/client/internal/model/vault/types.go
@@ -0,0 +1,35 @@
+package vault
+
+type Password struct {
+ Meta map[string]string
+ Login string
+ Password string
+}
+
+type Card struct {
+ Meta map[string]string
+ Holder string
+ Expired string
+ Number string
+ CSC string
+}
+
+type Text struct {
+ Meta map[string]string
+ Text string
+}
+
+type Binary struct {
+ Meta map[string]string
+ Value []byte
+}
+
+type BinaryLarge struct {
+ Meta map[string]string
+ Filename string
+}
+
+type IDName struct {
+ ID string
+ Name string
+}
diff --git a/client/internal/repository/storage/credsType.go b/client/internal/repository/storage/credsType.go
new file mode 100644
index 0000000..4b23445
--- /dev/null
+++ b/client/internal/repository/storage/credsType.go
@@ -0,0 +1,29 @@
+package storage
+
+import (
+ "errors"
+
+ "github.com/99designs/keyring"
+)
+
+var ErrUnknownCredentialsStorageType = errors.New("unknown credentials storage type")
+
+type Type string
+
+const (
+ Database Type = Type("database")
+ Keychain Type = Type(keyring.KeychainBackend)
+ SecretService Type = Type(keyring.SecretServiceBackend)
+ WinCred Type = Type(keyring.WinCredBackend)
+ Pass Type = Type(keyring.PassBackend)
+)
+
+func (t *Type) UnmarshalText(text []byte) error {
+ switch tt := Type(text); tt {
+ case Database, Keychain, SecretService, WinCred, Pass:
+ *t = tt
+ return nil
+ default:
+ return ErrUnknownCredentialsStorageType
+ }
+}
diff --git a/client/internal/repository/storage/error.go b/client/internal/repository/storage/error.go
new file mode 100644
index 0000000..b16c97e
--- /dev/null
+++ b/client/internal/repository/storage/error.go
@@ -0,0 +1,8 @@
+package storage
+
+import "errors"
+
+var (
+ ErrRecordNotFound = errors.New("record not found")
+ ErrNoRecordsAffected = errors.New("no records affected")
+)
diff --git a/client/internal/repository/storage/native/native.go b/client/internal/repository/storage/native/native.go
new file mode 100644
index 0000000..d619cb3
--- /dev/null
+++ b/client/internal/repository/storage/native/native.go
@@ -0,0 +1,81 @@
+package native
+
+import (
+ "bytes"
+ "context"
+ "encoding/gob"
+
+ "github.com/99designs/keyring"
+
+ "github.com/Karzoug/goph_keeper/client/internal/model"
+ "github.com/Karzoug/goph_keeper/client/internal/repository/storage"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+const appKey = "GOPHKEEPER"
+
+type nativeStorage struct {
+ keyring keyring.Keyring
+}
+
+func New(t storage.Type) (*nativeStorage, error) {
+ const op = "create native storage"
+
+ ring, err := keyring.Open(keyring.Config{
+ AllowedBackends: []keyring.BackendType{keyring.BackendType(t)},
+ })
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ return &nativeStorage{
+ keyring: ring,
+ }, nil
+}
+
+func (ns nativeStorage) SetCredentials(ctx context.Context, creds model.Credentials) error {
+ const op = "native: set credentials"
+
+ b := bytes.NewBuffer(nil)
+ enc := gob.NewEncoder(b)
+ if err := enc.Encode(creds); err != nil {
+ return e.Wrap(op, err)
+ }
+
+ if err := ns.keyring.Set(keyring.Item{
+ Key: appKey,
+ Data: b.Bytes(),
+ }); err != nil {
+ return e.Wrap(op, err)
+ }
+
+ return nil
+}
+
+func (ns nativeStorage) GetCredentials(context.Context) (model.Credentials, error) {
+ const op = "native: get credentials"
+
+ var res model.Credentials
+
+ item, err := ns.keyring.Get(appKey)
+ if err != nil {
+ return res, e.Wrap(op, err)
+ }
+
+ b := bytes.NewBuffer(item.Data)
+ dec := gob.NewDecoder(b)
+ if err := dec.Decode(&res); err != nil {
+ return res, e.Wrap(op, err)
+ }
+
+ return res, nil
+}
+func (ns nativeStorage) DeleteCredentials(context.Context) error {
+ const op = "native: delete credentials"
+
+ if err := ns.keyring.Remove(appKey); err != nil {
+ return e.Wrap(op, err)
+ }
+
+ return nil
+}
diff --git a/client/internal/repository/storage/sqllite/credentials.go b/client/internal/repository/storage/sqllite/credentials.go
new file mode 100644
index 0000000..f21cbde
--- /dev/null
+++ b/client/internal/repository/storage/sqllite/credentials.go
@@ -0,0 +1,116 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "github.com/Karzoug/goph_keeper/client/internal/model"
+ serr "github.com/Karzoug/goph_keeper/client/internal/repository/storage"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+const (
+ emailDBKey = "CREDS_EMAIL"
+ tokenDBKey = "CREDS_TOKEN"
+ encrKeyDBKey = "CREDS_ENCRKEY"
+)
+
+func (s *storage) SetCredentials(ctx context.Context, creds model.Credentials) error {
+ const op = "sqlite: set credentials"
+
+ encrKeyBytes := creds.EncrKey.Encode()
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ stmt, err := tx.PrepareContext(ctx, `INSERT INTO app(key,value) VALUES(?, ?)
+ ON CONFLICT(key)
+ DO UPDATE SET value=excluded.value;`)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ defer stmt.Close()
+
+ if _, err = stmt.ExecContext(ctx, emailDBKey, creds.Email); err != nil {
+ return err
+ }
+ if _, err = stmt.ExecContext(ctx, encrKeyDBKey, encrKeyBytes); err != nil {
+ return err
+ }
+ if len(creds.Token) == 0 {
+ return e.Wrap(op, tx.Commit())
+ }
+ if _, err = stmt.ExecContext(ctx, tokenDBKey, creds.Token); err != nil {
+ return err
+ }
+ return e.Wrap(op, tx.Commit())
+}
+
+func (s *storage) GetCredentials(ctx context.Context) (model.Credentials, error) {
+ const op = "sqlite: get credentials"
+
+ creds := model.Credentials{}
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return creds, e.Wrap(op, err)
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ stmt, err := tx.PrepareContext(ctx, `SELECT value FROM app WHERE key = ?;`)
+ if err != nil {
+ return creds, e.Wrap(op, err)
+ }
+ defer stmt.Close()
+
+ if err := stmt.QueryRowContext(ctx, emailDBKey).Scan(&creds.Email); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return creds, e.Wrap(op, serr.ErrRecordNotFound)
+ }
+ return creds, e.Wrap(op, err)
+ }
+ if err := stmt.QueryRowContext(ctx, tokenDBKey).Scan(&creds.Token); err != nil {
+ if !errors.Is(err, sql.ErrNoRows) {
+ return creds, e.Wrap(op, err)
+ }
+ }
+ var encrKeyBytes []byte
+ if err := stmt.QueryRowContext(ctx, encrKeyDBKey).Scan(&encrKeyBytes); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return creds, e.Wrap(op, serr.ErrRecordNotFound)
+ }
+ return creds, e.Wrap(op, err)
+ }
+ if err := creds.EncrKey.UnmarshalBinary(encrKeyBytes); err != nil {
+ return creds, e.Wrap(op, err)
+ }
+
+ return creds, e.Wrap(op, tx.Commit())
+}
+func (s *storage) DeleteCredentials(ctx context.Context) error {
+ const op = "sqlite: delete credentials"
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ stmt, err := tx.PrepareContext(ctx, `DELETE FROM app WHERE key = ?;`)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ defer stmt.Close()
+
+ if _, err = stmt.ExecContext(ctx, encrKeyDBKey); err != nil {
+ return err
+ }
+ if _, err = stmt.ExecContext(ctx, tokenDBKey); err != nil {
+ return err
+ }
+ return e.Wrap(op, tx.Commit())
+}
diff --git a/client/internal/repository/storage/sqllite/db.go b/client/internal/repository/storage/sqllite/db.go
new file mode 100644
index 0000000..f0823d8
--- /dev/null
+++ b/client/internal/repository/storage/sqllite/db.go
@@ -0,0 +1,61 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/golang-migrate/migrate/v4"
+ "github.com/golang-migrate/migrate/v4/database/sqlite"
+ "github.com/golang-migrate/migrate/v4/source/iofs"
+ _ "modernc.org/sqlite"
+
+ "github.com/Karzoug/goph_keeper/client/migrations"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+const (
+ duplicateKeyErrorCode = "1555"
+ dbFilename = "vault.db"
+)
+
+type storage struct {
+ db *sql.DB
+}
+
+func New(ctx context.Context) (*storage, error) {
+ op := "create sqlite storage"
+
+ db, err := sql.Open("sqlite", dbFilename)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ if err := db.PingContext(ctx); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ driver, err := sqlite.WithInstance(db, &sqlite.Config{})
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ d, err := iofs.New(migrations.FS, "sql")
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ m, err := migrate.NewWithInstance("iofs", d, "sqlite", driver)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ _ = m.Up()
+
+ return &storage{
+ db: db,
+ }, nil
+}
+
+func (s *storage) Close() error {
+ const op = "sqlite: close"
+
+ return e.Wrap(op, s.db.Close())
+}
diff --git a/client/internal/repository/storage/sqllite/owner.go b/client/internal/repository/storage/sqllite/owner.go
new file mode 100644
index 0000000..c5d3d0a
--- /dev/null
+++ b/client/internal/repository/storage/sqllite/owner.go
@@ -0,0 +1,58 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ serr "github.com/Karzoug/goph_keeper/client/internal/repository/storage"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+const ownerDBKey = "OWNER"
+
+func (s *storage) GetOwner(ctx context.Context) (string, error) {
+ const op = "sqlite: get owner"
+
+ var dbOwner string
+ err := s.db.QueryRowContext(ctx, `SELECT value FROM app WHERE key = ?;`, ownerDBKey).Scan(&dbOwner)
+ if err != nil {
+ if !errors.Is(err, sql.ErrNoRows) {
+ return "", e.Wrap(op, err)
+ }
+ return "", serr.ErrRecordNotFound
+ }
+ return dbOwner, nil
+}
+
+func (s *storage) SetOwner(ctx context.Context, email string) error {
+ const op = "sqlite: set owner"
+
+ res, err := s.db.ExecContext(ctx, `INSERT INTO app(key,value) VALUES(?, ?)
+ ON CONFLICT(key)
+ DO UPDATE SET value = excluded.value;`, ownerDBKey, email)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ count, err := res.RowsAffected()
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ if count == 0 {
+ return serr.ErrNoRecordsAffected
+ }
+
+ return nil
+}
+
+func (s *storage) ClearVault(ctx context.Context) error {
+ const op = "sqlite: clear vault"
+
+ _, err := s.db.ExecContext(ctx, `DELETE FROM vaults; DELETE FROM conflict_vaults;`)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ return nil
+}
diff --git a/client/internal/repository/storage/sqllite/vault.go b/client/internal/repository/storage/sqllite/vault.go
new file mode 100644
index 0000000..5a11b38
--- /dev/null
+++ b/client/internal/repository/storage/sqllite/vault.go
@@ -0,0 +1,194 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ serr "github.com/Karzoug/goph_keeper/client/internal/repository/storage"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+func (s *storage) ListVaultItems(ctx context.Context) ([]vault.Item, error) {
+ const op = "sqlite: list vault items"
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, name, type, value, client_updated_at, server_updated_at, is_deleted FROM vaults`)
+
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ defer rows.Close()
+
+ res := make([]vault.Item, 0)
+ for rows.Next() {
+ var item vault.Item
+ err := rows.Scan(&item.ID, &item.Name, &item.Type, &item.Value, &item.ClientUpdatedAt, &item.ServerUpdatedAt, &item.IsDeleted)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ res = append(res, item)
+ }
+
+ if err = rows.Err(); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ return res, nil
+}
+func (s *storage) ListVaultItemsIDName(ctx context.Context) ([]vault.IDName, error) {
+ const op = "sqlite: list vault items names"
+
+ rows, err := s.db.QueryContext(ctx, `SELECT id, name FROM vaults WHERE is_deleted = 0;`)
+
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ defer rows.Close()
+
+ res := make([]vault.IDName, 0)
+ for rows.Next() {
+ var item vault.IDName
+ err := rows.Scan(&item.ID, &item.Name)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ res = append(res, item)
+ }
+
+ if err = rows.Err(); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ return res, nil
+}
+
+func (s *storage) ListModifiedVaultItems(ctx context.Context) ([]vault.Item, error) {
+ const op = "sqlite: list modified vault items"
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, name, type, value, client_updated_at, server_updated_at, is_deleted
+ FROM vaults
+ WHERE server_updated_at < client_updated_at;`)
+
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ defer rows.Close()
+
+ res := make([]vault.Item, 0)
+ for rows.Next() {
+ var item vault.Item
+ err := rows.Scan(&item.ID, &item.Name, &item.Type, &item.Value, &item.ClientUpdatedAt, &item.ServerUpdatedAt, &item.IsDeleted)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ res = append(res, item)
+ }
+
+ if err = rows.Err(); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ return res, nil
+}
+
+func (s *storage) GetVaultItem(ctx context.Context, id string) (vault.Item, error) {
+ const op = "sqlite: get vault item"
+
+ item := vault.Item{ID: id}
+ err := s.db.QueryRowContext(ctx,
+ `SELECT name, type, value, client_updated_at, server_updated_at, is_deleted
+ FROM vaults
+ WHERE id = ?`, id).
+ Scan(&item.Name, &item.Type, &item.Value, &item.ClientUpdatedAt, &item.ServerUpdatedAt, &item.IsDeleted)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return item, serr.ErrRecordNotFound
+ }
+ }
+ return item, e.Wrap(op, err)
+}
+func (s *storage) SetVaultItem(ctx context.Context, item vault.Item) error {
+ const op = "sqlite: set vault item"
+
+ res, err := s.db.ExecContext(ctx,
+ `INSERT INTO vaults(id,name,type,value,client_updated_at,server_updated_at,is_deleted) VALUES(?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id)
+ DO UPDATE SET name = excluded.name, type = excluded.type, value=excluded.value,
+ client_updated_at=excluded.client_updated_at, server_updated_at=excluded.server_updated_at, is_deleted=excluded.is_deleted;`,
+ item.ID, item.Name, item.Type, item.Value, item.ClientUpdatedAt, item.ServerUpdatedAt, item.IsDeleted)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ count, err := res.RowsAffected()
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ if count == 0 {
+ return serr.ErrNoRecordsAffected
+ }
+
+ return nil
+}
+func (s *storage) DeleteVaultItem(ctx context.Context, id string) error {
+ const op = "sqlite: delete vault item"
+
+ res, err := s.db.ExecContext(ctx, `DELETE FROM vaults WHERE id = ?;`, id)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ count, err := res.RowsAffected()
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ if count == 0 {
+ return serr.ErrNoRecordsAffected
+ }
+
+ return nil
+}
+func (s *storage) MoveVaultItemToConflict(ctx context.Context, id string) error {
+ const op = "sqlite: move vault item to conflict"
+
+ tx, err := s.db.Begin()
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ res, err := s.db.ExecContext(ctx,
+ `INSERT INTO conflict_vaults (id,name,type,value,client_updated_at,server_updated_at,is_deleted)
+ SELECT id,name,type,value,client_updated_at,server_updated_at,is_deleted
+ FROM vaults WHERE id = ?;
+ DELETE FROM vaults WHERE id = ?;`, id, id)
+
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ count, err := res.RowsAffected()
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ if count == 0 {
+ return serr.ErrNoRecordsAffected
+ }
+
+ return nil
+}
+func (s *storage) GetLastServerUpdatedAt(ctx context.Context) (int64, error) {
+ const op = "sqlite: get last server updated at"
+
+ var t int64
+ err := s.db.QueryRowContext(ctx, `SELECT server_updated_at FROM vaults ORDER BY server_updated_at DESC LIMIT 1`).Scan(&t)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, serr.ErrRecordNotFound
+ }
+ }
+ return t, e.Wrap(op, err)
+}
diff --git a/client/internal/view/auth/auth.go b/client/internal/view/auth/auth.go
new file mode 100644
index 0000000..54db2e9
--- /dev/null
+++ b/client/internal/view/auth/auth.go
@@ -0,0 +1,118 @@
+package auth
+
+import (
+ "context"
+ "errors"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/Karzoug/goph_keeper/client/internal/client"
+ "github.com/Karzoug/goph_keeper/client/internal/view/common"
+)
+
+type View struct {
+ Frame *tview.Frame
+ form *tview.Form
+
+ baseContext context.Context
+ client *client.Client
+ msgCh chan<- any
+ appUpdateFn func(func()) *tview.Application
+
+ email string
+ password string
+}
+
+func New(c *client.Client, msgCh chan<- any, appUpdateFn func(func()) *tview.Application) View {
+ v := View{
+ client: c,
+ msgCh: msgCh,
+ appUpdateFn: appUpdateFn,
+ }
+ frame := tview.NewFrame(nil).
+ AddText("Enter email and password to login/register", true, tview.AlignLeft, tcell.ColorWhite)
+ v.Frame = frame
+ return v
+}
+
+func (v *View) Update(ctx context.Context) {
+ v.baseContext = ctx
+}
+
+func (v *View) Init() (common.KeyHandlerFnc, common.Help) {
+ form := tview.NewForm()
+ form.SetBorderPadding(1, 1, 0, 1)
+ form.AddInputField("Email", "", 35, nil, func(email string) {
+ v.email = email
+ })
+ form.AddPasswordField("Password", "", 35, '*', func(lastName string) {
+ v.password = lastName
+ })
+ form.AddButton("Login", func() {
+ go v.loginCmd()
+ })
+ form.AddButton("Register", func() {
+ go v.registerCmd()
+ })
+ v.form = form
+ v.Frame.SetPrimitive(form)
+
+ return nil, ""
+}
+
+func (v *View) loginCmd() {
+ ctx, cancel := context.WithTimeout(v.baseContext, common.StandartTimeout)
+ defer cancel()
+
+ err := v.client.Login(ctx, v.email, []byte(v.password))
+ if err != nil {
+ if errors.Is(err, client.ErrUserEmailNotVerified) {
+ // clear before go to list items
+ v.email = ""
+ v.password = ""
+ v.appUpdateFn(func() {
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ })
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.EmailVerification,
+ }
+ return
+ }
+ v.msgCh <- common.NewErrMsg(err)
+ return
+ }
+
+ // clear before go to list items
+ v.email = ""
+ v.password = ""
+ v.appUpdateFn(func() {
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ })
+
+ v.msgCh <- common.NewMsg("You are entered!")
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+}
+
+func (v *View) registerCmd() {
+ ctx, cancel := context.WithTimeout(v.baseContext, common.StandartTimeout)
+ defer cancel()
+
+ err := v.client.Register(ctx, v.email, []byte(v.password))
+ if err != nil {
+ v.msgCh <- common.NewErrMsg(err)
+ return
+ }
+
+ v.email = ""
+ v.password = ""
+ v.appUpdateFn(func() {
+ v.Init()
+ })
+
+ v.msgCh <- common.NewMsg("You are registered!")
+}
diff --git a/client/internal/view/common/common.go b/client/internal/view/common/common.go
new file mode 100644
index 0000000..62f2964
--- /dev/null
+++ b/client/internal/view/common/common.go
@@ -0,0 +1,69 @@
+package common
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+)
+
+type KeyHandlerFnc func(event *tcell.EventKey) *tcell.EventKey
+
+type Help string
+
+type Msg struct {
+ Time time.Time
+ msg string
+}
+
+func NewMsg(msg string) Msg {
+ return Msg{
+ Time: time.Now(),
+ msg: msg,
+ }
+}
+
+func (msg Msg) String() string {
+ return fmt.Sprintf("%s %s", msg.Time.Format(time.TimeOnly), msg.msg)
+}
+
+type ViewType string
+
+func (vt ViewType) String() string {
+ return string(vt)
+}
+
+type ToViewMsg struct {
+ ViewType ViewType
+ Value any
+}
+
+type ErrMsg struct {
+ Time time.Time
+ error
+}
+
+func NewErrMsg(err error) ErrMsg {
+ return ErrMsg{
+ Time: time.Now(),
+ error: err,
+ }
+}
+
+func (msg ErrMsg) Error() string {
+ return fmt.Sprintf("%s %s", msg.Time.Format(time.TimeOnly), msg.error)
+}
+
+const (
+ Auth ViewType = "Auth"
+ EmailVerification ViewType = "EmailVerification"
+ ListItems ViewType = "ListItems"
+ Item ViewType = "Item" // transitional view type, only to switch to another view
+ ChooseItemType ViewType = "ChooseItemType"
+ Password ViewType = "Password"
+ Card ViewType = "Card"
+ Text ViewType = "Text"
+ Binary ViewType = "Binary"
+)
+
+const StandartTimeout = 3 * time.Second
diff --git a/client/internal/view/email/email.go b/client/internal/view/email/email.go
new file mode 100644
index 0000000..c7d10db
--- /dev/null
+++ b/client/internal/view/email/email.go
@@ -0,0 +1,100 @@
+package email
+
+import (
+ "context"
+ "errors"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/Karzoug/goph_keeper/client/internal/client"
+ "github.com/Karzoug/goph_keeper/client/internal/view/common"
+)
+
+type View struct {
+ Frame *tview.Frame
+ input *tview.InputField
+
+ baseContext context.Context
+ client *client.Client
+ msgCh chan<- any
+ appUpdateFn func(func()) *tview.Application
+
+ code string
+}
+
+func New(c *client.Client, msgCh chan<- any, appUpdateFn func(func()) *tview.Application) View {
+ v := View{
+ client: c,
+ msgCh: msgCh,
+ appUpdateFn: appUpdateFn,
+ }
+
+ input := tview.NewInputField().
+ SetLabel("Enter the code from mail: ").
+ SetFieldWidth(6).
+ SetAcceptanceFunc(tview.InputFieldInteger)
+
+ input.SetDoneFunc(func(key tcell.Key) {
+ if key != tcell.KeyEnter {
+ return
+ }
+ code := v.input.GetText()
+ if len(code) < 6 {
+ msgCh <- errors.New("code too short")
+ return
+ }
+ v.code = code
+ input.SetDisabled(true)
+ go v.cmd()
+ })
+
+ frame := tview.NewFrame(input)
+
+ v.Frame = frame
+ v.input = input
+
+ return v
+}
+
+func (v *View) Update(ctx context.Context) {
+ v.baseContext = ctx
+}
+
+func (v *View) Init() (common.KeyHandlerFnc, common.Help) {
+ return v.keyHandler, "esc back • "
+}
+
+func (v *View) cmd() {
+ ctx, cancel := context.WithTimeout(v.baseContext, common.StandartTimeout)
+ defer cancel()
+
+ err := v.client.VerifyEmail(ctx, v.code)
+ if err != nil {
+ v.msgCh <- common.NewErrMsg(client.ErrInvalidEmailVerificationCode)
+ v.appUpdateFn(func() {
+ v.input.SetDisabled(false)
+ })
+ return
+ }
+ v.appUpdateFn(func() {
+ v.input.SetDisabled(false)
+ v.input.SetText("")
+ })
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+}
+
+func (v *View) keyHandler(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() { // nolint:exhaustive
+ case tcell.KeyEsc:
+ v.input.SetText("")
+ go func() {
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.Auth,
+ }
+ }()
+ }
+ return event
+}
diff --git a/client/internal/view/item/binary/binary.go b/client/internal/view/item/binary/binary.go
new file mode 100644
index 0000000..566cd7c
--- /dev/null
+++ b/client/internal/view/item/binary/binary.go
@@ -0,0 +1,187 @@
+package binary
+
+import (
+ "context"
+ "errors"
+ "os"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/Karzoug/goph_keeper/client/internal/client"
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ "github.com/Karzoug/goph_keeper/client/internal/view/common"
+ "github.com/Karzoug/goph_keeper/client/internal/view/item"
+ "github.com/Karzoug/goph_keeper/client/pkg/filepicker"
+)
+
+type View struct {
+ Frame *tview.Frame
+ form *tview.Form
+
+ baseContext context.Context
+ item vault.Item
+ value vault.Binary
+
+ path string
+ filename string
+
+ client *client.Client
+ msgCh chan<- any
+ app *tview.Application
+}
+
+func New(c *client.Client, msgCh chan<- any, app *tview.Application) (View, error) {
+ v := View{
+ client: c,
+ msgCh: msgCh,
+ app: app,
+ }
+
+ filepicker.SharedConfig.Application = v.app
+ path := c.RootPath()
+ if path == "" {
+ path, _ = os.UserHomeDir()
+ }
+ if fi, err := os.Stat(path); err != nil || !fi.IsDir() {
+ path, err = os.UserHomeDir()
+ if err != nil {
+ return v, err
+ }
+ }
+ filepicker.SharedConfig.RootPath = path
+
+ frame := tview.NewFrame(nil).
+ AddText("Save binary:", true, tview.AlignLeft, tcell.ColorWhite)
+
+ v.Frame = frame
+
+ return v, nil
+}
+
+func (v *View) Init() (common.KeyHandlerFnc, common.Help) {
+ nameInput := tview.NewInputField().SetLabel("Name: ").SetFieldWidth(40)
+ nameInput.SetBorderPadding(0, 1, 0, 0)
+
+ filepicker := filepicker.NewWindow(60, 15)
+
+ f := tview.NewForm()
+
+ if v.item.ID != "" {
+ modal := tview.NewModal().
+ SetText("Are you sure?").
+ AddButtons([]string{"Yes", "No"}).
+ SetDoneFunc(func(buttonIndex int, buttonLabel string) {
+ if buttonIndex == 0 {
+ go v.delete()
+ }
+ v.Frame.SetPrimitive(f)
+ })
+ f.AddTextView("Name", v.item.Name, 40, 1, false, false).
+ AddInputField("Filename", v.filename, 40, nil, func(filename string) {
+ v.filename = filename
+ }).
+ AddFormItem(filepicker).
+ AddButton("Decrypt and save", func() {
+ v.path = filepicker.GetCurrentPath()
+ go v.save()
+ }).
+ AddButton("Delete", func() {
+ v.Frame.SetPrimitive(modal)
+ })
+ } else {
+ f.AddInputField("Name", v.item.Name, 40, nil, func(name string) {
+ v.item.Name = name
+ }).
+ AddFormItem(filepicker).
+ AddButton("Save", func() {
+ v.path = filepicker.GetCurrentPath()
+ go v.save()
+ })
+ }
+
+ f.SetBorderPadding(1, 1, 0, 1)
+ v.Frame.SetPrimitive(f)
+
+ return v.keyHandler, "tab next • esc back • "
+}
+
+func (v *View) Update(ctx context.Context, vitem vault.Item, value any) error {
+ v.baseContext = ctx
+ v.item = vitem
+
+ if value == nil {
+ return nil
+ }
+ b, ok := value.(vault.Binary)
+ if !ok {
+ return item.ErrWrongItemType
+ }
+ v.value = b
+
+ return nil
+}
+
+func (v *View) save() {
+ var err error
+ if v.item.ID == "" {
+ err = v.createCmd(v.path)
+ } else {
+ err = v.saveOnDiskCmd(v.path, v.filename)
+ }
+ if err != nil {
+ v.msgCh <- common.NewErrMsg(err)
+ if errors.Is(err, client.ErrAppInternal) {
+ return
+ }
+ }
+
+ // clear before go to list items
+ v.value = vault.Binary{}
+ v.app.QueueUpdateDraw(func() {
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ })
+
+ v.msgCh <- common.NewMsg("Binary saved!")
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+}
+
+func (v *View) delete() {
+ if err := item.Delete(v.baseContext, v.client, v.item.ID); err != nil {
+ v.msgCh <- common.NewErrMsg(err)
+ if errors.Is(err, client.ErrAppInternal) {
+ return
+ }
+ }
+
+ // clear before go to list items
+ v.value = vault.Binary{}
+ v.app.QueueUpdateDraw(func() {
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ })
+
+ v.msgCh <- common.NewMsg("Item deleted!")
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+}
+
+func (v *View) keyHandler(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() { // nolint:exhaustive
+ case tcell.KeyEsc:
+ v.value = vault.Binary{}
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ go func() {
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+ }()
+ }
+
+ return event
+}
diff --git a/client/internal/view/item/binary/cmd.go b/client/internal/view/item/binary/cmd.go
new file mode 100644
index 0000000..0a61ebf
--- /dev/null
+++ b/client/internal/view/item/binary/cmd.go
@@ -0,0 +1,75 @@
+package binary
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ vc "github.com/Karzoug/goph_keeper/client/internal/view/common"
+ cvault "github.com/Karzoug/goph_keeper/common/model/vault"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+const maxValueSizeInDB = 1024 * 1024 // in bytes
+
+func (v *View) createCmd(path string) error {
+ fi, err := os.Stat(path)
+ if err != nil {
+ return e.Wrap("open file problem", err)
+ }
+ if fi.Size() > maxValueSizeInDB {
+ v.item.Type = cvault.BinaryLarge
+ // TODO: implement me
+ return errors.New("not implemented now, sorry")
+ } else {
+ v.item.Type = cvault.Binary
+ }
+ if fi.IsDir() {
+ return errors.New("you choose a directory, not a file")
+ }
+
+ value, err := os.ReadFile(path)
+ if err != nil {
+ return e.Wrap("open file problem", err)
+ }
+
+ ctx, cancel := context.WithTimeout(v.baseContext, vc.StandartTimeout)
+ defer cancel()
+
+ err = v.client.EncryptAndSetVaultItem(ctx, v.item, vault.Binary{Value: value})
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (v *View) saveOnDiskCmd(path, filename string) error {
+ fi, err := os.Stat(path)
+ if err != nil {
+ return e.Wrap("open directory problem", err)
+ }
+ if !fi.IsDir() {
+ path = filepath.Dir(path)
+ if fi, err := os.Stat(path); err != nil || !fi.IsDir() {
+ return errors.New("you choose a file, not a directory")
+ }
+ }
+
+ f, err := os.Create(filepath.Join(path, filename))
+ if err != nil {
+ return e.Wrap("create file problem", err)
+ }
+ defer f.Close()
+
+ w := bufio.NewWriter(f)
+ _, err = w.Write(v.value.Value)
+ if err != nil {
+ return e.Wrap("Write file problem", err)
+ }
+ defer w.Flush()
+
+ return nil
+}
diff --git a/client/internal/view/item/card/card.go b/client/internal/view/item/card/card.go
new file mode 100644
index 0000000..2acb13a
--- /dev/null
+++ b/client/internal/view/item/card/card.go
@@ -0,0 +1,155 @@
+package card
+
+import (
+ "context"
+ "errors"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/Karzoug/goph_keeper/client/internal/client"
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ "github.com/Karzoug/goph_keeper/client/internal/view/common"
+ "github.com/Karzoug/goph_keeper/client/internal/view/item"
+)
+
+type View struct {
+ Frame *tview.Frame
+ form *tview.Form
+
+ baseContext context.Context
+ item vault.Item
+ value vault.Card
+
+ client *client.Client
+ msgCh chan<- any
+ appUpdateFn func(func()) *tview.Application
+}
+
+func New(c *client.Client, msgCh chan<- any, appUpdateFn func(func()) *tview.Application) View {
+ v := View{
+ client: c,
+ msgCh: msgCh,
+ appUpdateFn: appUpdateFn,
+ }
+ frame := tview.NewFrame(nil).
+ AddText("Save card:", true, tview.AlignLeft, tcell.ColorWhite)
+ v.Frame = frame
+ return v
+}
+
+func (v *View) Init() (common.KeyHandlerFnc, common.Help) {
+ form := tview.NewForm().
+ AddInputField("Name", v.item.Name, 40, nil, func(name string) {
+ v.item.Name = name
+ }).
+ AddInputField("Number", v.value.Number, 40, tview.InputFieldInteger, func(number string) {
+ v.value.Number = number
+ }).
+ AddInputField("Cardholder", v.value.Holder, 40, tview.InputFieldMaxLength(40), func(holder string) {
+ v.value.Holder = holder
+ }).
+ AddInputField("Expired", v.value.Expired, 40, tview.InputFieldMaxLength(5), func(expired string) {
+ v.value.Expired = expired
+ }).
+ AddInputField("CVV/CVC", v.value.CSC, 40, tview.InputFieldMaxLength(4), func(csc string) {
+ v.value.CSC = csc
+ }).
+ AddButton("Save", func() {
+ go v.save()
+ })
+ modal := tview.NewModal().
+ SetText("Are you sure?").
+ AddButtons([]string{"Yes", "No"}).
+ SetDoneFunc(func(buttonIndex int, buttonLabel string) {
+ if buttonIndex == 0 {
+ go v.delete()
+ }
+ v.Frame.SetPrimitive(form)
+ })
+ if v.item.ID != "" {
+ form.AddButton("Delete", func() {
+ v.Frame.SetPrimitive(modal)
+ })
+ }
+ form.SetBorderPadding(1, 1, 0, 1)
+ v.form = form
+ v.Frame.SetPrimitive(form)
+
+ return v.keyHandler, "tab next • esc back • "
+}
+
+func (v *View) Update(ctx context.Context, vitem vault.Item, value any) error {
+ v.baseContext = ctx
+ v.item = vitem
+
+ if value == nil {
+ return nil
+ }
+ crd, ok := value.(vault.Card)
+ if !ok {
+ return item.ErrWrongItemType
+ }
+ v.value = crd
+
+ return nil
+}
+
+func (v *View) save() {
+ err := item.Set(v.baseContext, v.client, v.item, v.value)
+ if err != nil {
+ v.msgCh <- common.NewErrMsg(err)
+ if errors.Is(err, client.ErrAppInternal) {
+ return
+ }
+ }
+
+ // clear before go to list items
+ v.value = vault.Card{}
+ v.appUpdateFn(func() {
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ })
+
+ v.msgCh <- common.NewMsg("Password saved!")
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+}
+
+func (v *View) delete() {
+ if err := item.Delete(v.baseContext, v.client, v.item.ID); err != nil {
+ v.msgCh <- common.NewErrMsg(err)
+ if errors.Is(err, client.ErrAppInternal) {
+ return
+ }
+ }
+
+ // clear before go to list items
+ v.value = vault.Card{}
+ v.appUpdateFn(func() {
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ })
+
+ v.msgCh <- common.NewMsg("Item deleted!")
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+}
+
+func (v *View) keyHandler(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() { // nolint:exhaustive
+ case tcell.KeyEsc:
+ v.value = vault.Card{}
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ go func() {
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+ }()
+ }
+
+ return event
+}
diff --git a/client/internal/view/item/choose/choose.go b/client/internal/view/item/choose/choose.go
new file mode 100644
index 0000000..79724ac
--- /dev/null
+++ b/client/internal/view/item/choose/choose.go
@@ -0,0 +1,76 @@
+package choose
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/Karzoug/goph_keeper/client/internal/client"
+ "github.com/Karzoug/goph_keeper/client/internal/view/common"
+ "github.com/Karzoug/goph_keeper/common/model/vault"
+)
+
+type View struct {
+ Frame *tview.Frame
+ list *tview.List
+
+ client *client.Client
+ msgCh chan<- any
+
+ choices []string
+}
+
+func New(c *client.Client, msgCh chan<- any) View {
+ v := View{
+ client: c,
+ msgCh: msgCh,
+ choices: []string{"Password", "Card", "Text", "Binary"},
+ }
+
+ list := tview.NewList().ShowSecondaryText(false)
+ for i := 0; i < len(v.choices); i++ {
+ list = list.AddItem(v.choices[i], "", 0, nil)
+ }
+
+ frame := tview.NewFrame(list).
+ AddText("Choose a type of item:", true, tview.AlignLeft, tcell.ColorWhite)
+ v.list = list
+ v.Frame = frame
+
+ return v
+}
+
+func (v *View) Init() (common.KeyHandlerFnc, common.Help) {
+ return v.keyHandler, "tab next • esc back • "
+}
+
+func (v *View) keyHandler(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() { // nolint:exhaustive
+ case tcell.KeyEsc:
+ go func() {
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+ }()
+ case tcell.KeyEnter:
+ var value vault.ItemType
+ switch v.list.GetCurrentItem() {
+ case 0:
+ value = vault.Password
+ case 1:
+ value = vault.Card
+ case 2:
+ value = vault.Text
+ case 3:
+ value = vault.Binary
+ default:
+ return event
+ }
+ go func() {
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.Item,
+ Value: value,
+ }
+ }()
+ }
+ return event
+}
diff --git a/client/internal/view/item/cmd.go b/client/internal/view/item/cmd.go
new file mode 100644
index 0000000..b9bc8c6
--- /dev/null
+++ b/client/internal/view/item/cmd.go
@@ -0,0 +1,44 @@
+package item
+
+import (
+ "context"
+ "errors"
+
+ "github.com/Karzoug/goph_keeper/client/internal/client"
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ "github.com/Karzoug/goph_keeper/client/internal/view/common"
+)
+
+var ErrWrongItemType = errors.New("got wrong item type")
+
+func Get(ctx context.Context, c *client.Client, id string) (vault.Item, any, error) {
+ ctx, cancel := context.WithTimeout(ctx, common.StandartTimeout)
+ defer cancel()
+
+ item, value, err := c.DecryptAndGetVaultItem(ctx, id)
+ if err != nil {
+ return vault.Item{}, nil, err
+ }
+
+ return item, value, nil
+}
+
+func Set(ctx context.Context, c *client.Client, item vault.Item, value any) error {
+ ctx, cancel := context.WithTimeout(ctx, common.StandartTimeout)
+ defer cancel()
+
+ if err := c.EncryptAndSetVaultItem(ctx, item, value); err != nil {
+ return err
+ }
+ return nil
+}
+
+func Delete(ctx context.Context, c *client.Client, id string) error {
+ ctx, cancel := context.WithTimeout(ctx, common.StandartTimeout)
+ defer cancel()
+
+ if err := c.DeleteVaultItem(ctx, id); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/client/internal/view/item/password/password.go b/client/internal/view/item/password/password.go
new file mode 100644
index 0000000..05b3d33
--- /dev/null
+++ b/client/internal/view/item/password/password.go
@@ -0,0 +1,150 @@
+package password
+
+import (
+ "context"
+ "errors"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/Karzoug/goph_keeper/client/internal/client"
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ "github.com/Karzoug/goph_keeper/client/internal/view/common"
+ "github.com/Karzoug/goph_keeper/client/internal/view/item"
+)
+
+type View struct {
+ Frame *tview.Frame
+ form *tview.Form
+
+ baseContext context.Context
+ item vault.Item
+ value vault.Password
+
+ client *client.Client
+ msgCh chan<- any
+ appUpdateFn func(func()) *tview.Application
+}
+
+func New(c *client.Client, msgCh chan<- any, appUpdateFn func(func()) *tview.Application) View {
+ v := View{
+ client: c,
+ msgCh: msgCh,
+ appUpdateFn: appUpdateFn,
+ }
+ frame := tview.NewFrame(nil).
+ AddText("Save password:", true, tview.AlignLeft, tcell.ColorWhite)
+
+ v.Frame = frame
+ return v
+}
+
+func (v *View) Init() (common.KeyHandlerFnc, common.Help) {
+ form := tview.NewForm().
+ AddInputField("Name", v.item.Name, 40, nil, func(name string) {
+ v.item.Name = name
+ }).
+ AddInputField("Login", v.value.Login, 40, nil, func(login string) {
+ v.value.Login = login
+ }).
+ AddInputField("Password", v.value.Password, 40, nil, func(password string) {
+ v.value.Password = password
+ }).
+ AddButton("Save", func() {
+ go v.save()
+ })
+ modal := tview.NewModal().
+ SetText("Are you sure?").
+ AddButtons([]string{"Yes", "No"}).
+ SetDoneFunc(func(buttonIndex int, buttonLabel string) {
+ if buttonIndex == 0 {
+ go v.delete()
+ }
+ v.Frame.SetPrimitive(form)
+ })
+ if v.item.ID != "" {
+ form.AddButton("Delete", func() {
+ v.Frame.SetPrimitive(modal)
+ })
+ }
+ form.SetBorderPadding(1, 1, 0, 1)
+ v.form = form
+ v.Frame.SetPrimitive(form)
+
+ return v.keyHandler, "tab next • esc back • "
+}
+
+func (v *View) Update(ctx context.Context, vitem vault.Item, value any) error {
+ v.baseContext = ctx
+ v.item = vitem
+
+ if value == nil {
+ return nil
+ }
+ psw, ok := value.(vault.Password)
+ if !ok {
+ return item.ErrWrongItemType
+ }
+ v.value = psw
+
+ return nil
+}
+
+func (v *View) save() {
+ err := item.Set(v.baseContext, v.client, v.item, v.value)
+ if err != nil {
+ v.msgCh <- common.NewErrMsg(err)
+ if errors.Is(err, client.ErrAppInternal) {
+ return
+ }
+ }
+
+ // clear before go to list items
+ v.value = vault.Password{}
+ v.appUpdateFn(func() {
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ })
+
+ v.msgCh <- common.NewMsg("Password saved!")
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+}
+
+func (v *View) delete() {
+ if err := item.Delete(v.baseContext, v.client, v.item.ID); err != nil {
+ v.msgCh <- common.NewErrMsg(err)
+ if errors.Is(err, client.ErrAppInternal) {
+ return
+ }
+ }
+
+ // clear before go to list items
+ v.value = vault.Password{}
+ v.appUpdateFn(func() {
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ })
+
+ v.msgCh <- common.NewMsg("Item deleted!")
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+}
+
+func (v *View) keyHandler(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() { // nolint:exhaustive
+ case tcell.KeyEsc:
+ v.value = vault.Password{}
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ go func() {
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+ }()
+ }
+
+ return event
+}
diff --git a/client/internal/view/item/text/text.go b/client/internal/view/item/text/text.go
new file mode 100644
index 0000000..b899d08
--- /dev/null
+++ b/client/internal/view/item/text/text.go
@@ -0,0 +1,146 @@
+package text
+
+import (
+ "context"
+ "errors"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/Karzoug/goph_keeper/client/internal/client"
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ "github.com/Karzoug/goph_keeper/client/internal/view/common"
+ "github.com/Karzoug/goph_keeper/client/internal/view/item"
+)
+
+type View struct {
+ Frame *tview.Frame
+ form *tview.Form
+
+ baseContext context.Context
+ item vault.Item
+ value vault.Text
+
+ client *client.Client
+ msgCh chan<- any
+ appUpdateFn func(func()) *tview.Application
+}
+
+func New(c *client.Client, msgCh chan<- any, appUpdateFn func(func()) *tview.Application) View {
+ v := View{
+ client: c,
+ msgCh: msgCh,
+ appUpdateFn: appUpdateFn,
+ }
+ frame := tview.NewFrame(nil).
+ AddText("Save text:", true, tview.AlignLeft, tcell.ColorWhite)
+
+ v.Frame = frame
+ return v
+}
+
+func (v *View) Init() (common.KeyHandlerFnc, common.Help) {
+ form := tview.NewForm().
+ AddInputField("Name", v.item.Name, 60, nil, func(name string) {
+ v.item.Name = name
+ }).
+ AddTextArea("Text", v.value.Text, 60, 10, 0, func(text string) {
+ v.value.Text = text
+ }).
+ AddButton("Save", func() {
+ go v.save()
+ })
+ modal := tview.NewModal().
+ SetText("Are you sure?").
+ AddButtons([]string{"Yes", "No"}).
+ SetDoneFunc(func(buttonIndex int, buttonLabel string) {
+ if buttonIndex == 0 {
+ go v.delete()
+ }
+ v.Frame.SetPrimitive(form)
+ })
+ if v.item.ID != "" {
+ form.AddButton("Delete", func() {
+ v.Frame.SetPrimitive(modal)
+ })
+ }
+ form.SetBorderPadding(1, 1, 0, 1)
+ v.form = form
+ v.Frame.SetPrimitive(form)
+
+ return v.keyHandler, "tab next • esc back • "
+}
+
+func (v *View) Update(ctx context.Context, vitem vault.Item, value any) error {
+ v.baseContext = ctx
+ v.item = vitem
+
+ if value == nil {
+ return nil
+ }
+ txt, ok := value.(vault.Text)
+ if !ok {
+ return item.ErrWrongItemType
+ }
+ v.value = txt
+
+ return nil
+}
+
+func (v *View) save() {
+ if err := item.Set(v.baseContext, v.client, v.item, v.value); err != nil {
+ v.msgCh <- common.NewErrMsg(err)
+ if errors.Is(err, client.ErrAppInternal) {
+ return
+ }
+ }
+
+ // clear before go to list items
+ v.value = vault.Text{}
+ v.appUpdateFn(func() {
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ })
+
+ v.msgCh <- common.NewMsg("Text saved!")
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+}
+
+func (v *View) delete() {
+ if err := item.Delete(v.baseContext, v.client, v.item.ID); err != nil {
+ v.msgCh <- common.NewErrMsg(err)
+ if errors.Is(err, client.ErrAppInternal) {
+ return
+ }
+ }
+
+ // clear before go to list items
+ v.value = vault.Text{}
+ v.appUpdateFn(func() {
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ })
+
+ v.msgCh <- common.NewMsg("Item deleted!")
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+}
+
+func (v *View) keyHandler(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() { // nolint:exhaustive
+ case tcell.KeyEsc:
+ v.value = vault.Text{}
+ v.Frame.SetPrimitive(nil)
+ v.form = nil
+ go func() {
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+ }()
+ }
+
+ return event
+}
diff --git a/client/internal/view/list/list.go b/client/internal/view/list/list.go
new file mode 100644
index 0000000..45a3ad8
--- /dev/null
+++ b/client/internal/view/list/list.go
@@ -0,0 +1,114 @@
+package list
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/Karzoug/goph_keeper/client/internal/client"
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ "github.com/Karzoug/goph_keeper/client/internal/view/common"
+)
+
+const syncCmdTimeout = 5 * time.Second
+
+type View struct {
+ Frame *tview.Frame
+ list *tview.List
+
+ baseContext context.Context
+ client *client.Client
+ msgCh chan<- any
+ appUpdateFn func(func()) *tview.Application
+
+ idNames []vault.IDName
+}
+
+func New(c *client.Client, msgCh chan<- any, appUpdateFn func(func()) *tview.Application) View {
+ frame := tview.NewFrame(nil).
+ AddText("Your vault:", true, tview.AlignLeft, tcell.ColorWhite)
+ v := View{
+ client: c,
+ msgCh: msgCh,
+ Frame: frame,
+ appUpdateFn: appUpdateFn,
+ }
+ return v
+}
+
+func (v *View) Init() (common.KeyHandlerFnc, common.Help) {
+ list := tview.NewList().ShowSecondaryText(false)
+
+ for r := 0; r < len(v.idNames); r++ {
+ list = list.AddItem(v.idNames[r].Name, "", 0, nil)
+ }
+
+ v.list = list
+ v.Frame.SetPrimitive(list)
+
+ return v.keyHandler, "ctrl+n create • tab next • ctrl+u sync • "
+}
+
+func (v *View) Update(ctx context.Context) error {
+ v.baseContext = ctx
+
+ ctx, cancel := context.WithTimeout(v.baseContext, common.StandartTimeout)
+ defer cancel()
+
+ in, err := v.client.ListVaultItemsIDName(ctx)
+ if err != nil {
+ return err
+ }
+ v.idNames = in
+ return nil
+}
+
+func (v *View) sync() {
+ ctx, cancel := context.WithTimeout(v.baseContext, syncCmdTimeout)
+ defer cancel()
+
+ err := v.client.SyncVaultItems(ctx)
+ if err != nil {
+ if errors.Is(err, client.ErrUserNeedAuthentication) {
+ return
+ }
+ v.msgCh <- common.NewErrMsg(err)
+ return
+ }
+
+ if err := v.Update(v.baseContext); err != nil {
+ v.msgCh <- common.NewErrMsg(err)
+ return
+ }
+
+ v.appUpdateFn(func() {
+ v.Init()
+ })
+
+ v.msgCh <- common.NewMsg("Vault synced!")
+}
+
+func (v *View) keyHandler(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() { // nolint:exhaustive
+ case tcell.KeyCtrlN:
+ go func() {
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ChooseItemType,
+ }
+ }()
+ case tcell.KeyEnter:
+ curr := v.list.GetCurrentItem()
+ go func() {
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.Item,
+ Value: v.idNames[curr].ID,
+ }
+ }()
+ case tcell.KeyCtrlU:
+ go v.sync()
+ }
+ return event
+}
diff --git a/client/internal/view/view.go b/client/internal/view/view.go
new file mode 100644
index 0000000..e4a2d0b
--- /dev/null
+++ b/client/internal/view/view.go
@@ -0,0 +1,361 @@
+package view
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+
+ "github.com/Karzoug/goph_keeper/client/internal/client"
+ "github.com/Karzoug/goph_keeper/client/internal/model/vault"
+ "github.com/Karzoug/goph_keeper/client/internal/view/auth"
+ "github.com/Karzoug/goph_keeper/client/internal/view/common"
+ "github.com/Karzoug/goph_keeper/client/internal/view/email"
+ "github.com/Karzoug/goph_keeper/client/internal/view/item"
+ "github.com/Karzoug/goph_keeper/client/internal/view/item/binary"
+ "github.com/Karzoug/goph_keeper/client/internal/view/item/card"
+ "github.com/Karzoug/goph_keeper/client/internal/view/item/choose"
+ "github.com/Karzoug/goph_keeper/client/internal/view/item/password"
+ "github.com/Karzoug/goph_keeper/client/internal/view/item/text"
+ "github.com/Karzoug/goph_keeper/client/internal/view/list"
+ cvault "github.com/Karzoug/goph_keeper/common/model/vault"
+)
+
+const (
+ refreshNotificationInterval = 500 * time.Millisecond
+ notificationLifetime = 5 * time.Second
+)
+
+type View struct {
+ app *tview.Application
+ root *tview.Flex
+ pages *tview.Pages
+ currentPage common.ViewType
+ msgCh chan any
+ baseContext context.Context
+
+ client *client.Client
+
+ subviews struct {
+ auth auth.View
+ list list.View
+ email email.View
+ choose choose.View
+ password password.View
+ text text.View
+ card card.View
+ binary binary.View
+ }
+ footer struct {
+ msgText *tview.TextView
+ msg common.Msg
+ errText *tview.TextView
+ err common.ErrMsg
+ statusText *tview.TextView
+ helpText *tview.TextView
+ }
+}
+
+func New(client *client.Client) (*View, error) {
+ var app = tview.NewApplication()
+ var pages = tview.NewPages()
+
+ v := &View{
+ msgCh: make(chan any),
+ client: client,
+ }
+
+ // create footer to view app info
+ v.footer.msgText = tview.NewTextView().SetTextColor(tcell.ColorBlue)
+ v.footer.errText = tview.NewTextView().SetTextColor(tcell.ColorRed)
+ v.footer.statusText = tview.NewTextView().SetTextColor(tcell.ColorRed)
+ v.footer.helpText = tview.NewTextView().SetTextColor(tcell.ColorGray)
+ v.footer.helpText.SetBorderPadding(1, 0, 0, 0)
+
+ // create subviews
+ v.subviews.auth = auth.New(client, v.msgCh, app.QueueUpdateDraw)
+ v.subviews.list = list.New(client, v.msgCh, app.QueueUpdateDraw)
+ v.subviews.email = email.New(client, v.msgCh, app.QueueUpdateDraw)
+ v.subviews.choose = choose.New(client, v.msgCh)
+ v.subviews.password = password.New(client, v.msgCh, app.QueueUpdateDraw)
+ v.subviews.text = text.New(client, v.msgCh, app.QueueUpdateDraw)
+ v.subviews.card = card.New(client, v.msgCh, app.QueueUpdateDraw)
+ var err error
+ v.subviews.binary, err = binary.New(client, v.msgCh, app)
+ if err != nil {
+ return nil, err
+ }
+
+ // add subviews to pages
+ pages.AddPage(common.Auth.String(), v.subviews.auth.Frame, true, false)
+ pages.AddPage(common.ListItems.String(), v.subviews.list.Frame, true, false)
+ pages.AddPage(common.EmailVerification.String(), v.subviews.email.Frame, true, false)
+ pages.AddPage(common.ChooseItemType.String(), v.subviews.choose.Frame, true, false)
+ pages.AddPage(common.Password.String(), v.subviews.password.Frame, true, false)
+ pages.AddPage(common.Text.String(), v.subviews.text.Frame, true, false)
+ pages.AddPage(common.Card.String(), v.subviews.card.Frame, true, false)
+ pages.AddPage(common.Binary.String(), v.subviews.binary.Frame, true, false)
+ pages.SetChangedFunc(v.initSubview)
+
+ // create header to view app info
+ header := fmt.Sprintf("Goph Keeper: your password manager & vault app\nversion: %s", client.Version())
+ headerTextView := tview.NewTextView().SetText(header)
+
+ // build main view
+ var flex = tview.NewFlex()
+ flex.SetDirection(tview.FlexRow).
+ AddItem(headerTextView, 2, 0, false).
+ AddItem(tview.NewFlex().
+ AddItem(pages, 0, 1, false), 0, 1, true).
+ AddItem(v.footer.msgText, 1, 0, false).
+ AddItem(v.footer.errText, 1, 0, false).
+ AddItem(v.footer.statusText, 1, 0, false).
+ AddItem(v.footer.helpText, 2, 0, false)
+
+ v.pages = pages
+ v.root = flex
+ v.app = app.SetRoot(flex, true).EnableMouse(true)
+
+ return v, nil
+}
+
+func (v *View) handleMsgs() {
+ for {
+ select {
+ case <-v.baseContext.Done():
+ return
+ case msg := <-v.msgCh:
+ switch msg := msg.(type) {
+ case common.ErrMsg:
+ v.footer.err = msg
+ v.app.QueueUpdateDraw(func() {
+ v.footer.errText.SetText("Error: " + msg.Error())
+ })
+ case common.Msg:
+ v.footer.msg = msg
+ v.app.QueueUpdateDraw(func() {
+ v.footer.msgText.SetText(msg.String())
+ })
+ case common.ToViewMsg:
+ v.currentPage = msg.ViewType
+
+ switch msg.ViewType { //nolint:exhaustive
+ case common.ListItems:
+ if err := v.subviews.list.Update(v.baseContext); err != nil {
+ err = common.NewErrMsg(err)
+ v.app.QueueUpdateDraw(func() {
+ v.footer.errText.SetText("Error: " + err.Error())
+ })
+ return
+ }
+ case common.Item:
+ v.toItem(msg.Value)
+ case common.EmailVerification:
+ v.subviews.email.Update(v.baseContext)
+ case common.Auth:
+ v.subviews.auth.Update(v.baseContext)
+ }
+
+ v.pages.SwitchToPage(v.currentPage.String())
+ }
+ }
+ }
+}
+
+func (v *View) Run(ctx context.Context) error {
+ baseContext, cancel := context.WithCancel(ctx)
+ v.baseContext = baseContext
+
+ var err error
+ // run main loop to handle tview events
+ go func() {
+ _ = v.app.Run() // (!) run only after setting base context
+ cancel()
+ }()
+
+ // if parent context is canceled stop tview app
+ go func() {
+ <-ctx.Done()
+ v.app.Stop()
+ }()
+
+ // run loop to handle msgs
+ go v.handleMsgs()
+ // run loop to update notifications in footer
+ go v.updateNotifications()
+
+ // go to start subview
+ if v.client.HasLocalCredintials() {
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.ListItems,
+ }
+ } else {
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.Auth,
+ }
+ }
+
+ <-v.baseContext.Done()
+ return err
+}
+
+func (v *View) updateNotifications() {
+ ticker := time.NewTicker(refreshNotificationInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ if time.Since(v.footer.err.Time) > notificationLifetime {
+ v.app.QueueUpdateDraw(func() {
+ v.footer.errText.Clear()
+ })
+ }
+ if time.Since(v.footer.msg.Time) > notificationLifetime {
+ v.app.QueueUpdateDraw(func() {
+ v.footer.msgText.Clear()
+ })
+ }
+
+ if v.client.HasToken() {
+ v.app.QueueUpdateDraw(func() {
+ v.footer.statusText.Clear()
+ })
+ } else {
+ v.app.QueueUpdateDraw(func() {
+ v.footer.statusText.SetText("You are not logged in to the server, the data is not synced.")
+ })
+ }
+ case <-v.baseContext.Done():
+ return
+ }
+ }
+}
+
+func (v *View) initSubview() {
+ var (
+ kh common.KeyHandlerFnc
+ hlp common.Help
+ )
+ switch v.currentPage { //nolint:exhaustive
+ case common.Auth:
+ kh, hlp = v.subviews.auth.Init()
+ v.app.SetFocus(v.subviews.auth.Frame)
+ case common.EmailVerification:
+ kh, hlp = v.subviews.email.Init()
+ v.app.SetFocus(v.subviews.email.Frame)
+ case common.ListItems:
+ kh, hlp = v.subviews.list.Init()
+ v.app.SetFocus(v.subviews.list.Frame)
+ case common.ChooseItemType:
+ kh, hlp = v.subviews.choose.Init()
+ v.app.SetFocus(v.subviews.choose.Frame)
+ case common.Password:
+ kh, hlp = v.subviews.password.Init()
+ v.app.SetFocus(v.subviews.password.Frame)
+ case common.Text:
+ kh, hlp = v.subviews.text.Init()
+ v.app.SetFocus(v.subviews.text.Frame)
+ case common.Card:
+ kh, hlp = v.subviews.card.Init()
+ v.app.SetFocus(v.subviews.card.Frame)
+ case common.Binary:
+ kh, hlp = v.subviews.binary.Init()
+ v.app.SetFocus(v.subviews.binary.Frame)
+ }
+
+ v.root.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ if event.Key() == tcell.KeyCtrlC { // replace standard tview ctrl+c handler to do nothing
+ return nil
+ }
+ if event.Key() == tcell.KeyCtrlX {
+ go func() {
+ ctx, cancel := context.WithTimeout(v.baseContext, common.StandartTimeout)
+ defer cancel()
+
+ if err := v.client.Logout(ctx); err != nil {
+ v.msgCh <- common.NewErrMsg(err)
+ }
+
+ v.msgCh <- common.ToViewMsg{
+ ViewType: common.Auth,
+ }
+ }()
+ return event
+ }
+ if kh == nil {
+ return event
+ }
+ return kh(event)
+ })
+
+ sb := strings.Builder{}
+ sb.WriteString(string(hlp))
+ if v.client.HasLocalCredintials() {
+ sb.WriteString("ctrl+x logout • ")
+ }
+ sb.WriteString("ctrl+c quit")
+
+ v.footer.helpText.SetText(sb.String())
+}
+
+func (v *View) toItem(value any) {
+ var (
+ vitem vault.Item
+ dv any
+ )
+
+ switch value := value.(type) {
+ case cvault.ItemType:
+ vitem.Type = value
+ case string:
+ var err error
+ if vitem, dv, err = item.Get(v.baseContext, v.client, value); err != nil {
+ v.app.QueueUpdateDraw(func() {
+ v.footer.errText.SetText("Error: " + err.Error())
+ })
+ return
+ }
+ default:
+ v.currentPage = common.ListItems
+ return
+ }
+
+ switch vitem.Type { // nolint:exhaustive
+ case cvault.Password:
+ v.currentPage = common.Password
+ if err := v.subviews.password.Update(v.baseContext, vitem, dv); err != nil {
+ err = common.NewErrMsg(err)
+ v.app.QueueUpdateDraw(func() {
+ v.footer.errText.SetText("Error: " + err.Error())
+ })
+ }
+ case cvault.Card:
+ v.currentPage = common.Card
+ if err := v.subviews.card.Update(v.baseContext, vitem, dv); err != nil {
+ err = common.NewErrMsg(err)
+ v.app.QueueUpdateDraw(func() {
+ v.footer.errText.SetText("Error: " + err.Error())
+ })
+ }
+ case cvault.Text:
+ v.currentPage = common.Text
+ if err := v.subviews.text.Update(v.baseContext, vitem, dv); err != nil {
+ err = common.NewErrMsg(err)
+ v.app.QueueUpdateDraw(func() {
+ v.footer.errText.SetText("Error: " + err.Error())
+ })
+ }
+ case cvault.Binary:
+ v.currentPage = common.Binary
+ if err := v.subviews.binary.Update(v.baseContext, vitem, dv); err != nil {
+ err = common.NewErrMsg(err)
+ v.app.QueueUpdateDraw(func() {
+ v.footer.errText.SetText("Error: " + err.Error())
+ })
+ }
+ }
+}
diff --git a/client/migrations/migration.go b/client/migrations/migration.go
new file mode 100644
index 0000000..6992eaa
--- /dev/null
+++ b/client/migrations/migration.go
@@ -0,0 +1,6 @@
+package migrations
+
+import "embed"
+
+//go:embed sql/*.sql
+var FS embed.FS
diff --git a/client/migrations/sql/000001_create_vaults.down.sql b/client/migrations/sql/000001_create_vaults.down.sql
new file mode 100644
index 0000000..4cb093a
--- /dev/null
+++ b/client/migrations/sql/000001_create_vaults.down.sql
@@ -0,0 +1 @@
+DROP table vaults;
\ No newline at end of file
diff --git a/client/migrations/sql/000001_create_vaults.up.sql b/client/migrations/sql/000001_create_vaults.up.sql
new file mode 100644
index 0000000..c6b92de
--- /dev/null
+++ b/client/migrations/sql/000001_create_vaults.up.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS vaults (
+ id TEXT PRIMARY KEY NOT NULL,
+ name TEXT,
+ type INTEGER NOT NULL,
+ value BLOB,
+ server_updated_at INTEGER,
+ client_updated_at INTEGER NOT NULL);
\ No newline at end of file
diff --git a/client/migrations/sql/000002_create_app.down.sql b/client/migrations/sql/000002_create_app.down.sql
new file mode 100644
index 0000000..d129b8c
--- /dev/null
+++ b/client/migrations/sql/000002_create_app.down.sql
@@ -0,0 +1 @@
+DROP table app;
\ No newline at end of file
diff --git a/client/migrations/sql/000002_create_app.up.sql b/client/migrations/sql/000002_create_app.up.sql
new file mode 100644
index 0000000..a70ec37
--- /dev/null
+++ b/client/migrations/sql/000002_create_app.up.sql
@@ -0,0 +1,3 @@
+CREATE TABLE IF NOT EXISTS app (
+ key TEXT PRIMARY KEY NOT NULL,
+ value TEXT);
\ No newline at end of file
diff --git a/client/migrations/sql/000003_create_conflict_vaults.down.sql b/client/migrations/sql/000003_create_conflict_vaults.down.sql
new file mode 100644
index 0000000..f5b5d99
--- /dev/null
+++ b/client/migrations/sql/000003_create_conflict_vaults.down.sql
@@ -0,0 +1 @@
+DROP table conflict_vaults;
\ No newline at end of file
diff --git a/client/migrations/sql/000003_create_conflict_vaults.up.sql b/client/migrations/sql/000003_create_conflict_vaults.up.sql
new file mode 100644
index 0000000..2755b6f
--- /dev/null
+++ b/client/migrations/sql/000003_create_conflict_vaults.up.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS conflict_vaults (
+ id TEXT PRIMARY KEY NOT NULL,
+ name TEXT,
+ type INTEGER NOT NULL,
+ value BLOB,
+ server_updated_at INTEGER,
+ client_updated_at INTEGER NOT NULL);
\ No newline at end of file
diff --git a/client/migrations/sql/000004_deleted_vault_item.down.sql b/client/migrations/sql/000004_deleted_vault_item.down.sql
new file mode 100644
index 0000000..63bd961
--- /dev/null
+++ b/client/migrations/sql/000004_deleted_vault_item.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE vaults
+DROP COLUMN is_deleted;
\ No newline at end of file
diff --git a/client/migrations/sql/000004_deleted_vault_item.up.sql b/client/migrations/sql/000004_deleted_vault_item.up.sql
new file mode 100644
index 0000000..7063a69
--- /dev/null
+++ b/client/migrations/sql/000004_deleted_vault_item.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE vaults
+ADD is_deleted INTEGER NOT NULL DEFAULT 0;
\ No newline at end of file
diff --git a/client/pkg/crypto/bytes.go b/client/pkg/crypto/bytes.go
new file mode 100644
index 0000000..c633b40
--- /dev/null
+++ b/client/pkg/crypto/bytes.go
@@ -0,0 +1,40 @@
+package crypto
+
+import (
+ "crypto/subtle"
+ "runtime"
+)
+
+// Wipe takes a buffer and wipes it with zeroes.
+func Wipe(buf []byte) {
+ for i := range buf {
+ buf[i] = 0
+ }
+
+ // This should keep buf's backing array live and thus prevent dead store
+ // elimination, according to discussion at
+ // https://github.com/golang/go/issues/33325 .
+ runtime.KeepAlive(buf)
+}
+
+// Copy is identical to Go's builtin copy function except the copying is done in constant time. This is to mitigate against side-channel attacks.
+func Copy(dst, src []byte) {
+ if len(dst) > len(src) {
+ subtle.ConstantTimeCopy(1, dst[:len(src)], src)
+ } else if len(dst) < len(src) {
+ subtle.ConstantTimeCopy(1, dst, src[:len(dst)])
+ } else {
+ subtle.ConstantTimeCopy(1, dst, src)
+ }
+}
+
+// Move is identical to Copy except it wipes the source buffer after the copy operation is executed.
+func Move(dst, src []byte) {
+ Copy(dst, src)
+ Wipe(src)
+}
+
+// Equal does a constant-time comparison of two byte slices. This is to mitigate against side-channel attacks.
+func Equal(x, y []byte) bool {
+ return subtle.ConstantTimeCompare(x, y) == 1
+}
diff --git a/client/pkg/crypto/chacha20poly1305/crypt.go b/client/pkg/crypto/chacha20poly1305/crypt.go
new file mode 100644
index 0000000..2e76400
--- /dev/null
+++ b/client/pkg/crypto/chacha20poly1305/crypt.go
@@ -0,0 +1,95 @@
+package chacha20poly1305
+
+import (
+ "crypto/rand"
+ "encoding/binary"
+ "errors"
+ "io"
+
+ "golang.org/x/crypto/chacha20poly1305"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+const chunkSize = 1024 * 32 // chunkSize in bytes
+
+func GetCapacityForEncryptedValue(len int) int {
+ return (chacha20poly1305.NonceSizeX+chunkSize+chacha20poly1305.Overhead)*(len/chunkSize) +
+ chacha20poly1305.NonceSizeX + (len % chunkSize) + chacha20poly1305.Overhead
+}
+
+func Encrypt(r io.Reader, w io.Writer, encrKey []byte) error {
+ aead, err := chacha20poly1305.NewX(encrKey)
+ if err != nil {
+ return e.Wrap("creating cipher", err)
+ }
+
+ buf := make([]byte, chunkSize)
+ var adCounter uint32 = 0
+ adCounterBytes := make([]byte, 4)
+
+ for {
+ n, err := r.Read(buf)
+
+ if n > 0 {
+ nonce := make([]byte, aead.NonceSize(), aead.NonceSize()+n+aead.Overhead())
+ if m, err := rand.Read(nonce[0:aead.NonceSize()]); err != nil || m != aead.NonceSize() {
+ return errors.New("generated ramdom nonce has wrong size")
+ }
+
+ binary.LittleEndian.PutUint32(adCounterBytes, adCounter)
+ msg := buf[:n]
+ encryptedMsg := aead.Seal(nonce, nonce, msg, adCounterBytes)
+ if _, err := w.Write(encryptedMsg); err != nil {
+ return e.Wrap("error writing", err)
+ }
+ adCounter += 1
+ }
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return e.Wrap("error reading", err)
+ }
+ }
+ return nil
+}
+
+func Decrypt(r io.Reader, w io.Writer, encrKey []byte) error {
+ aead, err := chacha20poly1305.NewX(encrKey)
+ if err != nil {
+ return e.Wrap("creating cipher", err)
+ }
+ decbufsize := aead.NonceSize() + chunkSize + aead.Overhead()
+
+ buf := make([]byte, decbufsize)
+ var adCounter uint32 = 0
+ adCounterBytes := make([]byte, 4)
+
+ for {
+ n, err := r.Read(buf)
+ if n > 0 {
+ encryptedMsg := buf[:n]
+ if len(encryptedMsg) < aead.NonceSize() {
+ return errors.New("ciphertext too short")
+ }
+ nonce, ciphertext := encryptedMsg[:aead.NonceSize()], encryptedMsg[aead.NonceSize():]
+ binary.LittleEndian.PutUint32(adCounterBytes, adCounter)
+ plaintext, err := aead.Open(nil, nonce, ciphertext, adCounterBytes)
+ if err != nil {
+ return errors.New("decrypt ciphertext error: wrong password or data")
+ }
+ if _, err := w.Write(plaintext); err != nil {
+ return e.Wrap("error writing", err)
+ }
+ }
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return e.Wrap("error reading: ", err)
+ }
+ adCounter += 1
+ }
+ return nil
+}
diff --git a/client/pkg/crypto/chacha20poly1305/crypt_test.go b/client/pkg/crypto/chacha20poly1305/crypt_test.go
new file mode 100644
index 0000000..b20c8f1
--- /dev/null
+++ b/client/pkg/crypto/chacha20poly1305/crypt_test.go
@@ -0,0 +1,51 @@
+package chacha20poly1305
+
+import (
+ "bytes"
+ "math/rand"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEncryptDecrypt(t *testing.T) {
+ encr := strings.NewReader("Hello, gophers!")
+ encw := bytes.NewBuffer(nil)
+ key := make([]byte, 32)
+ keyTooSmall := make([]byte, 14)
+ rand.Read(key)
+ rand.Read(keyTooSmall)
+
+ err := Encrypt(encr, encw, keyTooSmall)
+ require.Error(t, err)
+
+ err = Encrypt(encr, encw, key)
+ require.NoError(t, err)
+
+ encBytes := encw.Bytes()
+
+ decr := bytes.NewReader(encBytes)
+ decw := bytes.NewBuffer(nil)
+ err = Decrypt(decr, decw, key)
+ require.NoError(t, err)
+
+ assert.Equal(t, "Hello, gophers!", decw.String())
+
+ err = Decrypt(decr, decw, keyTooSmall)
+ assert.Error(t, err)
+}
+
+func TestGetCapacityForEncryptedValue(t *testing.T) {
+ encr := strings.NewReader("Hello, gophers!")
+ encw := bytes.NewBuffer(nil)
+ key := make([]byte, 32)
+ rand.Read(key)
+
+ err := Encrypt(encr, encw, key)
+ require.NoError(t, err)
+
+ enccap := GetCapacityForEncryptedValue(int(encr.Size()))
+ assert.LessOrEqual(t, encw.Len(), enccap)
+}
diff --git a/client/pkg/filepicker/LICENSE b/client/pkg/filepicker/LICENSE
new file mode 100644
index 0000000..116cb9c
--- /dev/null
+++ b/client/pkg/filepicker/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2019 bannzai
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/client/pkg/filepicker/README.md b/client/pkg/filepicker/README.md
new file mode 100644
index 0000000..7f8795c
--- /dev/null
+++ b/client/pkg/filepicker/README.md
@@ -0,0 +1,3 @@
+fork: https://github.com/bannzai/itree
+
+modifying for purposes tui filepicker
\ No newline at end of file
diff --git a/client/pkg/filepicker/config.go b/client/pkg/filepicker/config.go
new file mode 100644
index 0000000..01fb35c
--- /dev/null
+++ b/client/pkg/filepicker/config.go
@@ -0,0 +1,12 @@
+package filepicker
+
+import "github.com/rivo/tview"
+
+type Config struct {
+ RootPath string
+ *tview.Application
+}
+
+var SharedConfig = Config{
+ Application: nil,
+}
diff --git a/client/pkg/filepicker/error_field.go b/client/pkg/filepicker/error_field.go
new file mode 100644
index 0000000..b57927c
--- /dev/null
+++ b/client/pkg/filepicker/error_field.go
@@ -0,0 +1,20 @@
+package filepicker
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+type ErrorField struct {
+ *tview.TextView
+}
+
+func NewErrorField() ErrorField {
+ textView := tview.NewTextView().
+ SetTextColor(tcell.ColorRed).
+ SetDynamicColors(true).
+ SetRegions(true).
+ SetWordWrap(true)
+
+ return ErrorField{textView}
+}
diff --git a/client/pkg/filepicker/form_layout.go b/client/pkg/filepicker/form_layout.go
new file mode 100644
index 0000000..b2b1a5b
--- /dev/null
+++ b/client/pkg/filepicker/form_layout.go
@@ -0,0 +1,17 @@
+package filepicker
+
+import "github.com/rivo/tview"
+
+type FormLayout struct {
+ *tview.Grid
+}
+
+func NewFormLayout(form tview.Primitive, errorField ErrorField) FormLayout {
+ return FormLayout{
+ tview.NewGrid().
+ SetRows(0, 1).
+ SetColumns(0).
+ AddItem(form, 0, 0, 1, 1, 0, 0, true).
+ AddItem(errorField, 1, 0, 1, 1, 0, 30, false),
+ }
+}
diff --git a/client/pkg/filepicker/helper.go b/client/pkg/filepicker/helper.go
new file mode 100644
index 0000000..a2f7f0f
--- /dev/null
+++ b/client/pkg/filepicker/helper.go
@@ -0,0 +1,45 @@
+package filepicker
+
+import (
+ "path/filepath"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+func extractNodeReference(node *tview.TreeNode) *nodeReference {
+ return node.GetReference().(*nodeReference)
+}
+
+func createTreeNode(fileName string, isDir bool, parent *tview.TreeNode) *tview.TreeNode {
+ var parentPath string
+
+ if parent == nil {
+ parentPath = SharedConfig.RootPath
+ } else {
+ reference, ok := parent.GetReference().(*nodeReference)
+ if !ok {
+ parentPath = SharedConfig.RootPath
+ } else {
+ parentPath = reference.path
+ }
+ }
+
+ var color tcell.Color
+ if isDir {
+ color = tcell.ColorGreen
+ } else {
+ color = tview.Styles.PrimaryTextColor
+ }
+
+ return tview.NewTreeNode(fileName).
+ SetReference(
+ newNodeReference(
+ filepath.Join(parentPath, fileName),
+ isDir,
+ parent,
+ ),
+ ).
+ SetSelectable(true).
+ SetColor(color)
+}
diff --git a/client/pkg/filepicker/node_reference.go b/client/pkg/filepicker/node_reference.go
new file mode 100644
index 0000000..5ea6397
--- /dev/null
+++ b/client/pkg/filepicker/node_reference.go
@@ -0,0 +1,17 @@
+package filepicker
+
+import "github.com/rivo/tview"
+
+type nodeReference struct {
+ path string
+ isDir bool
+ parentNode *tview.TreeNode
+}
+
+func newNodeReference(path string, isDir bool, parentNode *tview.TreeNode) *nodeReference {
+ return &nodeReference{
+ path: path,
+ isDir: isDir,
+ parentNode: parentNode,
+ }
+}
diff --git a/client/pkg/filepicker/page.go b/client/pkg/filepicker/page.go
new file mode 100644
index 0000000..46e3a08
--- /dev/null
+++ b/client/pkg/filepicker/page.go
@@ -0,0 +1,25 @@
+package filepicker
+
+import (
+ "github.com/rivo/tview"
+)
+
+type page interface {
+ name() string
+ view() tview.Primitive
+}
+
+type pages struct {
+ *tview.Pages
+}
+
+func newPages(root *root, pageViews ...page) pages {
+ pagesView := tview.NewPages()
+ pagesView.AddPage("main", root, true, true)
+ for _, page := range pageViews {
+ pagesView.AddPage(page.name(), page.view(), true, false)
+ }
+ return pages{
+ Pages: pagesView,
+ }
+}
diff --git a/client/pkg/filepicker/root.go b/client/pkg/filepicker/root.go
new file mode 100644
index 0000000..f494641
--- /dev/null
+++ b/client/pkg/filepicker/root.go
@@ -0,0 +1,27 @@
+package filepicker
+
+import (
+ "github.com/rivo/tview"
+)
+
+type root struct {
+ *tview.Grid
+ *tree
+}
+
+func newRoot(window *Window) *root {
+ tree := newTree(window)
+ view := &root{
+ Grid: tview.NewGrid().
+ SetRows(0).
+ SetColumns(30, 0),
+ }
+ view.tree = tree
+ view.addTree()
+ return view
+}
+
+func (view *root) addTree() {
+ view.
+ AddItem(view.tree.TreeView, 0, 0, 1, 1, 0, 0, true)
+}
diff --git a/client/pkg/filepicker/tree.go b/client/pkg/filepicker/tree.go
new file mode 100644
index 0000000..98674d5
--- /dev/null
+++ b/client/pkg/filepicker/tree.go
@@ -0,0 +1,83 @@
+package filepicker
+
+import (
+ "os"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+const nameOfTree = "Tree"
+
+type tree struct {
+ *tview.TreeView
+ originalRootNode *tview.TreeNode
+ window *Window
+}
+
+func newTree(window *Window) *tree {
+ rootDir := SharedConfig.RootPath
+ root := tview.NewTreeNode(rootDir).
+ SetColor(tcell.ColorRed).
+ SetReference(newNodeReference(rootDir, true, nil))
+ tree := &tree{
+ TreeView: tview.NewTreeView().
+ SetRoot(root).
+ SetCurrentNode(root),
+ originalRootNode: root,
+ window: window,
+ }
+ tree.addNode(root, rootDir)
+
+ tree.SetSelectedFunc(func(node *tview.TreeNode) {
+ tree.expandOrAddNode(node)
+ })
+
+ return tree
+}
+
+func (tree tree) name() string {
+ return nameOfTree
+}
+
+func (tree tree) view() tview.Primitive {
+ return tree.TreeView
+}
+
+func (tree tree) GetCurrentPath() string {
+ nodeReference := extractNodeReference(tree.GetCurrentNode())
+ if nodeReference == nil {
+ return ""
+ }
+ return nodeReference.path
+}
+
+func (tree *tree) addNode(directoryNode *tview.TreeNode, path string) {
+ files, err := os.ReadDir(path)
+ if err != nil {
+ panic(err)
+ }
+ for _, file := range files {
+ node := createTreeNode(file.Name(), file.IsDir(), directoryNode)
+ directoryNode.AddChild(node)
+ }
+}
+
+func (tree tree) expandOrAddNode(node *tview.TreeNode) {
+ reference := node.GetReference()
+ if reference == nil {
+ return
+ }
+ nodeReference := reference.(*nodeReference)
+ if !nodeReference.isDir {
+ return
+ }
+
+ children := node.GetChildren()
+ if len(children) == 0 {
+ path := nodeReference.path
+ tree.addNode(node, path)
+ } else {
+ node.SetExpanded(!node.IsExpanded())
+ }
+}
diff --git a/client/pkg/filepicker/window.go b/client/pkg/filepicker/window.go
new file mode 100644
index 0000000..490697a
--- /dev/null
+++ b/client/pkg/filepicker/window.go
@@ -0,0 +1,78 @@
+package filepicker
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+type transition interface {
+ tview.Primitive
+ AddAndSwitchToPage(name string, item tview.Primitive, resize bool) *tview.Pages
+ RemovePage(name string) *tview.Pages
+}
+
+type Window struct {
+ *root
+ transition
+ // A callback function set by the Form class and called when the user leaves
+ // this form item.
+ finished func(tcell.Key)
+}
+
+func NewWindow(width, height int) *Window {
+ window := &Window{}
+ window.root = newRoot(window)
+ window.transition = newPages(
+ window.root,
+ )
+ window.SetRect(0, 0, width, height)
+ return window
+}
+
+// Confirm for tview.Primitive
+func (window Window) Draw(screen tcell.Screen) {
+ window.transition.Draw(screen)
+}
+func (window Window) GetRect() (x int, y int, width int, height int) {
+ return window.transition.GetRect()
+}
+func (window Window) SetRect(x, y, width, height int) {
+ window.transition.SetRect(x, y, width, height)
+}
+func (window *Window) GetFieldHeight() int {
+ _, _, _, height := window.transition.GetRect() //nolint:dogsled
+ return height
+}
+func (window *Window) GetFieldWidth() int {
+ _, _, width, _ := window.transition.GetRect() //nolint:dogsled
+ return width
+}
+func (window *Window) GetLabel() string {
+ return ""
+}
+func (window *Window) SetDisabled(disabled bool) tview.FormItem {
+ return window
+}
+func (window *Window) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem {
+ window.finished = handler
+ return window
+}
+func (window *Window) SetFormAttributes(labelWidth int, labelColor, bgColor, fieldTextColor, fieldBgColor tcell.Color) tview.FormItem {
+ return window
+}
+func (window Window) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+ return func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+ if key := event.Key(); key == tcell.KeyTab && window.finished != nil {
+ window.finished(key)
+ return
+ }
+ h := window.transition.InputHandler()
+ h(event, setFocus)
+ }
+}
+func (window Window) Focus(delegate func(p tview.Primitive)) {
+ window.transition.Focus(delegate)
+}
+func (window Window) Blur() {
+ window.transition.Blur()
+}
diff --git a/common/api/keeper.proto b/common/api/keeper.proto
new file mode 100644
index 0000000..95799a9
--- /dev/null
+++ b/common/api/keeper.proto
@@ -0,0 +1,64 @@
+syntax = "proto3";
+
+package common.grpc;
+
+option go_package = "common/grpc";
+
+message RegisterRequest {
+ string email = 1;
+ bytes hash = 2;
+}
+
+message RegisterResponse {
+}
+
+message LoginRequest {
+ string email = 1;
+ bytes hash = 2;
+ string email_code = 3;
+}
+
+message LoginResponse {
+ string token = 1;
+}
+
+enum IType {
+ UNKNOWN = 0;
+ PASSWORD = 1;
+ CARD = 2;
+ TEXT = 3;
+ BINARY = 4;
+ BINARY_LARGE = 5;
+}
+
+message VaultItem {
+ string id = 1;
+ string name = 2;
+ IType itype = 3;
+ bytes value = 4;
+ int64 server_updated_at = 5;
+ bool is_deleted = 6;
+}
+
+message ListVaultItemsRequest {
+ int64 since = 1;
+}
+
+message ListVaultItemsResponse {
+ repeated VaultItem items = 1;
+}
+
+message SetVaultItemRequest {
+ VaultItem item = 1;
+}
+
+message SetVaultItemResponse {
+ int64 server_updated_at = 1;
+}
+
+service GophKeeperService {
+ rpc Register(RegisterRequest) returns (RegisterResponse);
+ rpc Login(LoginRequest) returns (LoginResponse);
+ rpc ListVaultItems(ListVaultItemsRequest) returns (ListVaultItemsResponse);
+ rpc SetVaultItem(SetVaultItemRequest) returns (SetVaultItemResponse);
+}
\ No newline at end of file
diff --git a/common/go.mod b/common/go.mod
index 9351e5b..b09668d 100644
--- a/common/go.mod
+++ b/common/go.mod
@@ -1,3 +1,13 @@
module github.com/Karzoug/goph_keeper/common
-go 1.20
+go 1.21
+
+require (
+ google.golang.org/grpc v1.56.2
+ google.golang.org/protobuf v1.30.0
+)
+
+require (
+ github.com/golang/protobuf v1.5.3 // indirect
+ google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
+)
diff --git a/common/go.sum b/common/go.sum
new file mode 100644
index 0000000..e6ea53a
--- /dev/null
+++ b/common/go.sum
@@ -0,0 +1,14 @@
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
+google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
+google.golang.org/grpc v1.56.2 h1:fVRFRnXvU+x6C4IlHZewvJOVHoOv1TUuQyoRsYnB4bI=
+google.golang.org/grpc v1.56.2/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
+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.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
+google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
diff --git a/common/grpc/error.go b/common/grpc/error.go
new file mode 100644
index 0000000..d31e2b9
--- /dev/null
+++ b/common/grpc/error.go
@@ -0,0 +1,36 @@
+package grpc
+
+import (
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+var (
+ // ErrInternal returned if server for some reason cannot process the request and there is no more appropriate error.
+ ErrInternal = status.Error(codes.Internal, "internal errors")
+ // ErrUserAlreadyExists returned on registration when the user already exists.
+ ErrUserAlreadyExists = status.Error(codes.AlreadyExists, "user already exists")
+ // ErrUserNotExists returned on login when the user does not exist.
+ ErrUserNotExists = status.Error(codes.NotFound, "user not exists")
+ // ErrUserEmailNotVerified returned on login when the user email is not yet verified.
+ ErrUserEmailNotVerified = status.Error(codes.Unauthenticated, "user email not verified")
+ // ErrUserInvalidHash returned if the passed authentication hash is not valid i.e. does not match the user.
+ // See also ErrInvalidHashFormat description.
+ ErrUserInvalidHash = status.Error(codes.Unauthenticated, "user hash not valid")
+ // ErrUserNeedAuthentication returned if user token is no longer valid (expired for example).
+ ErrUserNeedAuthentication = status.Error(codes.Unauthenticated, "user need authentication")
+ // ErrInvalidTokenFormat returned if user send token with invalid format.
+ ErrInvalidTokenFormat = status.Error(codes.InvalidArgument, "user invalid token format")
+ // ErrInvalidEmailFormat returned if format of the passed email is not valid.
+ ErrInvalidEmailFormat = status.Error(codes.InvalidArgument, "invalid email format")
+ // ErrInvalidHashFormat returned if format of the passed authentication hash is not valid.
+ // See also ErrUserInvalidHash description.
+ ErrInvalidHashFormat = status.Error(codes.InvalidArgument, "invalid hash format")
+ // ErrEmptyAuthData returned if no auth data is passed.
+ ErrEmptyAuthData = status.Error(codes.InvalidArgument, "empty auth data")
+ // ErrVaultItemVersionConflict returned if the client has changed the item and is trying to send it to the server,
+ // but the item on the server has changed since the last synchronization.
+ ErrVaultItemConflictVersion = status.Error(codes.InvalidArgument, "vault item: conflict version")
+ // ErrVaultItemValueTooBig returned if the client is trying to send large data using an inappropriate method.
+ ErrVaultItemValueTooBig = status.Error(codes.OutOfRange, "vault item: big value")
+)
diff --git a/common/grpc/keeper.pb.go b/common/grpc/keeper.pb.go
new file mode 100644
index 0000000..fc964c6
--- /dev/null
+++ b/common/grpc/keeper.pb.go
@@ -0,0 +1,814 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.31.0
+// protoc v3.12.4
+// source: common/api/keeper.proto
+
+package grpc
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type IType int32
+
+const (
+ IType_UNKNOWN IType = 0
+ IType_PASSWORD IType = 1
+ IType_CARD IType = 2
+ IType_TEXT IType = 3
+ IType_BINARY IType = 4
+ IType_BINARY_LARGE IType = 5
+)
+
+// Enum value maps for IType.
+var (
+ IType_name = map[int32]string{
+ 0: "UNKNOWN",
+ 1: "PASSWORD",
+ 2: "CARD",
+ 3: "TEXT",
+ 4: "BINARY",
+ 5: "BINARY_LARGE",
+ }
+ IType_value = map[string]int32{
+ "UNKNOWN": 0,
+ "PASSWORD": 1,
+ "CARD": 2,
+ "TEXT": 3,
+ "BINARY": 4,
+ "BINARY_LARGE": 5,
+ }
+)
+
+func (x IType) Enum() *IType {
+ p := new(IType)
+ *p = x
+ return p
+}
+
+func (x IType) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (IType) Descriptor() protoreflect.EnumDescriptor {
+ return file_common_api_keeper_proto_enumTypes[0].Descriptor()
+}
+
+func (IType) Type() protoreflect.EnumType {
+ return &file_common_api_keeper_proto_enumTypes[0]
+}
+
+func (x IType) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use IType.Descriptor instead.
+func (IType) EnumDescriptor() ([]byte, []int) {
+ return file_common_api_keeper_proto_rawDescGZIP(), []int{0}
+}
+
+type RegisterRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"`
+ Hash []byte `protobuf:"bytes,2,opt,name=hash,proto3" json:"hash,omitempty"`
+}
+
+func (x *RegisterRequest) Reset() {
+ *x = RegisterRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_common_api_keeper_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *RegisterRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RegisterRequest) ProtoMessage() {}
+
+func (x *RegisterRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_common_api_keeper_proto_msgTypes[0]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RegisterRequest.ProtoReflect.Descriptor instead.
+func (*RegisterRequest) Descriptor() ([]byte, []int) {
+ return file_common_api_keeper_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *RegisterRequest) GetEmail() string {
+ if x != nil {
+ return x.Email
+ }
+ return ""
+}
+
+func (x *RegisterRequest) GetHash() []byte {
+ if x != nil {
+ return x.Hash
+ }
+ return nil
+}
+
+type RegisterResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+}
+
+func (x *RegisterResponse) Reset() {
+ *x = RegisterResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_common_api_keeper_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *RegisterResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RegisterResponse) ProtoMessage() {}
+
+func (x *RegisterResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_common_api_keeper_proto_msgTypes[1]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RegisterResponse.ProtoReflect.Descriptor instead.
+func (*RegisterResponse) Descriptor() ([]byte, []int) {
+ return file_common_api_keeper_proto_rawDescGZIP(), []int{1}
+}
+
+type LoginRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"`
+ Hash []byte `protobuf:"bytes,2,opt,name=hash,proto3" json:"hash,omitempty"`
+ EmailCode string `protobuf:"bytes,3,opt,name=email_code,json=emailCode,proto3" json:"email_code,omitempty"`
+}
+
+func (x *LoginRequest) Reset() {
+ *x = LoginRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_common_api_keeper_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *LoginRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*LoginRequest) ProtoMessage() {}
+
+func (x *LoginRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_common_api_keeper_proto_msgTypes[2]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead.
+func (*LoginRequest) Descriptor() ([]byte, []int) {
+ return file_common_api_keeper_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *LoginRequest) GetEmail() string {
+ if x != nil {
+ return x.Email
+ }
+ return ""
+}
+
+func (x *LoginRequest) GetHash() []byte {
+ if x != nil {
+ return x.Hash
+ }
+ return nil
+}
+
+func (x *LoginRequest) GetEmailCode() string {
+ if x != nil {
+ return x.EmailCode
+ }
+ return ""
+}
+
+type LoginResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
+}
+
+func (x *LoginResponse) Reset() {
+ *x = LoginResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_common_api_keeper_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *LoginResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*LoginResponse) ProtoMessage() {}
+
+func (x *LoginResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_common_api_keeper_proto_msgTypes[3]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead.
+func (*LoginResponse) Descriptor() ([]byte, []int) {
+ return file_common_api_keeper_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *LoginResponse) GetToken() string {
+ if x != nil {
+ return x.Token
+ }
+ return ""
+}
+
+type VaultItem struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
+ Itype IType `protobuf:"varint,3,opt,name=itype,proto3,enum=common.grpc.IType" json:"itype,omitempty"`
+ Value []byte `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"`
+ ServerUpdatedAt int64 `protobuf:"varint,5,opt,name=server_updated_at,json=serverUpdatedAt,proto3" json:"server_updated_at,omitempty"`
+ IsDeleted bool `protobuf:"varint,6,opt,name=is_deleted,json=isDeleted,proto3" json:"is_deleted,omitempty"`
+}
+
+func (x *VaultItem) Reset() {
+ *x = VaultItem{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_common_api_keeper_proto_msgTypes[4]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *VaultItem) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VaultItem) ProtoMessage() {}
+
+func (x *VaultItem) ProtoReflect() protoreflect.Message {
+ mi := &file_common_api_keeper_proto_msgTypes[4]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use VaultItem.ProtoReflect.Descriptor instead.
+func (*VaultItem) Descriptor() ([]byte, []int) {
+ return file_common_api_keeper_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *VaultItem) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *VaultItem) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *VaultItem) GetItype() IType {
+ if x != nil {
+ return x.Itype
+ }
+ return IType_UNKNOWN
+}
+
+func (x *VaultItem) GetValue() []byte {
+ if x != nil {
+ return x.Value
+ }
+ return nil
+}
+
+func (x *VaultItem) GetServerUpdatedAt() int64 {
+ if x != nil {
+ return x.ServerUpdatedAt
+ }
+ return 0
+}
+
+func (x *VaultItem) GetIsDeleted() bool {
+ if x != nil {
+ return x.IsDeleted
+ }
+ return false
+}
+
+type ListVaultItemsRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Since int64 `protobuf:"varint,1,opt,name=since,proto3" json:"since,omitempty"`
+}
+
+func (x *ListVaultItemsRequest) Reset() {
+ *x = ListVaultItemsRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_common_api_keeper_proto_msgTypes[5]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ListVaultItemsRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListVaultItemsRequest) ProtoMessage() {}
+
+func (x *ListVaultItemsRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_common_api_keeper_proto_msgTypes[5]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListVaultItemsRequest.ProtoReflect.Descriptor instead.
+func (*ListVaultItemsRequest) Descriptor() ([]byte, []int) {
+ return file_common_api_keeper_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *ListVaultItemsRequest) GetSince() int64 {
+ if x != nil {
+ return x.Since
+ }
+ return 0
+}
+
+type ListVaultItemsResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Items []*VaultItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
+}
+
+func (x *ListVaultItemsResponse) Reset() {
+ *x = ListVaultItemsResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_common_api_keeper_proto_msgTypes[6]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ListVaultItemsResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListVaultItemsResponse) ProtoMessage() {}
+
+func (x *ListVaultItemsResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_common_api_keeper_proto_msgTypes[6]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListVaultItemsResponse.ProtoReflect.Descriptor instead.
+func (*ListVaultItemsResponse) Descriptor() ([]byte, []int) {
+ return file_common_api_keeper_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *ListVaultItemsResponse) GetItems() []*VaultItem {
+ if x != nil {
+ return x.Items
+ }
+ return nil
+}
+
+type SetVaultItemRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Item *VaultItem `protobuf:"bytes,1,opt,name=item,proto3" json:"item,omitempty"`
+}
+
+func (x *SetVaultItemRequest) Reset() {
+ *x = SetVaultItemRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_common_api_keeper_proto_msgTypes[7]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *SetVaultItemRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SetVaultItemRequest) ProtoMessage() {}
+
+func (x *SetVaultItemRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_common_api_keeper_proto_msgTypes[7]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SetVaultItemRequest.ProtoReflect.Descriptor instead.
+func (*SetVaultItemRequest) Descriptor() ([]byte, []int) {
+ return file_common_api_keeper_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *SetVaultItemRequest) GetItem() *VaultItem {
+ if x != nil {
+ return x.Item
+ }
+ return nil
+}
+
+type SetVaultItemResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ ServerUpdatedAt int64 `protobuf:"varint,1,opt,name=server_updated_at,json=serverUpdatedAt,proto3" json:"server_updated_at,omitempty"`
+}
+
+func (x *SetVaultItemResponse) Reset() {
+ *x = SetVaultItemResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_common_api_keeper_proto_msgTypes[8]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *SetVaultItemResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SetVaultItemResponse) ProtoMessage() {}
+
+func (x *SetVaultItemResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_common_api_keeper_proto_msgTypes[8]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SetVaultItemResponse.ProtoReflect.Descriptor instead.
+func (*SetVaultItemResponse) Descriptor() ([]byte, []int) {
+ return file_common_api_keeper_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *SetVaultItemResponse) GetServerUpdatedAt() int64 {
+ if x != nil {
+ return x.ServerUpdatedAt
+ }
+ return 0
+}
+
+var File_common_api_keeper_proto protoreflect.FileDescriptor
+
+var file_common_api_keeper_proto_rawDesc = []byte{
+ 0x0a, 0x17, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x6b, 0x65, 0x65,
+ 0x70, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x63, 0x6f, 0x6d, 0x6d, 0x6f,
+ 0x6e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x22, 0x3b, 0x0a, 0x0f, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74,
+ 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61,
+ 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12,
+ 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x68,
+ 0x61, 0x73, 0x68, 0x22, 0x12, 0x0a, 0x10, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52,
+ 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x57, 0x0a, 0x0c, 0x4c, 0x6f, 0x67, 0x69, 0x6e,
+ 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x12, 0x0a,
+ 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x68, 0x61, 0x73,
+ 0x68, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18,
+ 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x43, 0x6f, 0x64, 0x65,
+ 0x22, 0x25, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+ 0x65, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xba, 0x01, 0x0a, 0x09, 0x56, 0x61, 0x75, 0x6c,
+ 0x74, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x69, 0x74, 0x79,
+ 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x12, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f,
+ 0x6e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x49, 0x54, 0x79, 0x70, 0x65, 0x52, 0x05, 0x69, 0x74,
+ 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01,
+ 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x73, 0x65, 0x72,
+ 0x76, 0x65, 0x72, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05,
+ 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x70, 0x64, 0x61,
+ 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x6c, 0x65,
+ 0x74, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x65, 0x6c,
+ 0x65, 0x74, 0x65, 0x64, 0x22, 0x2d, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x61, 0x75, 0x6c,
+ 0x74, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a,
+ 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x69,
+ 0x6e, 0x63, 0x65, 0x22, 0x46, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x61, 0x75, 0x6c, 0x74,
+ 0x49, 0x74, 0x65, 0x6d, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a,
+ 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63,
+ 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x56, 0x61, 0x75, 0x6c, 0x74,
+ 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0x41, 0x0a, 0x13, 0x53,
+ 0x65, 0x74, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x65, 0x71, 0x75, 0x65,
+ 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
+ 0x32, 0x16, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x56,
+ 0x61, 0x75, 0x6c, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x42,
+ 0x0a, 0x14, 0x53, 0x65, 0x74, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x65,
+ 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72,
+ 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x03, 0x52, 0x0f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64,
+ 0x41, 0x74, 0x2a, 0x54, 0x0a, 0x05, 0x49, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55,
+ 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x50, 0x41, 0x53, 0x53,
+ 0x57, 0x4f, 0x52, 0x44, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x43, 0x41, 0x52, 0x44, 0x10, 0x02,
+ 0x12, 0x08, 0x0a, 0x04, 0x54, 0x45, 0x58, 0x54, 0x10, 0x03, 0x12, 0x0a, 0x0a, 0x06, 0x42, 0x49,
+ 0x4e, 0x41, 0x52, 0x59, 0x10, 0x04, 0x12, 0x10, 0x0a, 0x0c, 0x42, 0x49, 0x4e, 0x41, 0x52, 0x59,
+ 0x5f, 0x4c, 0x41, 0x52, 0x47, 0x45, 0x10, 0x05, 0x32, 0xcc, 0x02, 0x0a, 0x11, 0x47, 0x6f, 0x70,
+ 0x68, 0x4b, 0x65, 0x65, 0x70, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x47,
+ 0x0a, 0x08, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x12, 0x1c, 0x2e, 0x63, 0x6f, 0x6d,
+ 0x6d, 0x6f, 0x6e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65,
+ 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f,
+ 0x6e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52,
+ 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e,
+ 0x12, 0x19, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x4c,
+ 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x63, 0x6f,
+ 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52,
+ 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x56,
+ 0x61, 0x75, 0x6c, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x6d, 0x6d,
+ 0x6f, 0x6e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x61, 0x75, 0x6c,
+ 0x74, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e,
+ 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x4c, 0x69, 0x73, 0x74,
+ 0x56, 0x61, 0x75, 0x6c, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+ 0x73, 0x65, 0x12, 0x53, 0x0a, 0x0c, 0x53, 0x65, 0x74, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x49, 0x74,
+ 0x65, 0x6d, 0x12, 0x20, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x67, 0x72, 0x70, 0x63,
+ 0x2e, 0x53, 0x65, 0x74, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x65, 0x71,
+ 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x67, 0x72,
+ 0x70, 0x63, 0x2e, 0x53, 0x65, 0x74, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x52,
+ 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0d, 0x5a, 0x0b, 0x63, 0x6f, 0x6d, 0x6d, 0x6f,
+ 0x6e, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+ file_common_api_keeper_proto_rawDescOnce sync.Once
+ file_common_api_keeper_proto_rawDescData = file_common_api_keeper_proto_rawDesc
+)
+
+func file_common_api_keeper_proto_rawDescGZIP() []byte {
+ file_common_api_keeper_proto_rawDescOnce.Do(func() {
+ file_common_api_keeper_proto_rawDescData = protoimpl.X.CompressGZIP(file_common_api_keeper_proto_rawDescData)
+ })
+ return file_common_api_keeper_proto_rawDescData
+}
+
+var file_common_api_keeper_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
+var file_common_api_keeper_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
+var file_common_api_keeper_proto_goTypes = []interface{}{
+ (IType)(0), // 0: common.grpc.IType
+ (*RegisterRequest)(nil), // 1: common.grpc.RegisterRequest
+ (*RegisterResponse)(nil), // 2: common.grpc.RegisterResponse
+ (*LoginRequest)(nil), // 3: common.grpc.LoginRequest
+ (*LoginResponse)(nil), // 4: common.grpc.LoginResponse
+ (*VaultItem)(nil), // 5: common.grpc.VaultItem
+ (*ListVaultItemsRequest)(nil), // 6: common.grpc.ListVaultItemsRequest
+ (*ListVaultItemsResponse)(nil), // 7: common.grpc.ListVaultItemsResponse
+ (*SetVaultItemRequest)(nil), // 8: common.grpc.SetVaultItemRequest
+ (*SetVaultItemResponse)(nil), // 9: common.grpc.SetVaultItemResponse
+}
+var file_common_api_keeper_proto_depIdxs = []int32{
+ 0, // 0: common.grpc.VaultItem.itype:type_name -> common.grpc.IType
+ 5, // 1: common.grpc.ListVaultItemsResponse.items:type_name -> common.grpc.VaultItem
+ 5, // 2: common.grpc.SetVaultItemRequest.item:type_name -> common.grpc.VaultItem
+ 1, // 3: common.grpc.GophKeeperService.Register:input_type -> common.grpc.RegisterRequest
+ 3, // 4: common.grpc.GophKeeperService.Login:input_type -> common.grpc.LoginRequest
+ 6, // 5: common.grpc.GophKeeperService.ListVaultItems:input_type -> common.grpc.ListVaultItemsRequest
+ 8, // 6: common.grpc.GophKeeperService.SetVaultItem:input_type -> common.grpc.SetVaultItemRequest
+ 2, // 7: common.grpc.GophKeeperService.Register:output_type -> common.grpc.RegisterResponse
+ 4, // 8: common.grpc.GophKeeperService.Login:output_type -> common.grpc.LoginResponse
+ 7, // 9: common.grpc.GophKeeperService.ListVaultItems:output_type -> common.grpc.ListVaultItemsResponse
+ 9, // 10: common.grpc.GophKeeperService.SetVaultItem:output_type -> common.grpc.SetVaultItemResponse
+ 7, // [7:11] is the sub-list for method output_type
+ 3, // [3:7] is the sub-list for method input_type
+ 3, // [3:3] is the sub-list for extension type_name
+ 3, // [3:3] is the sub-list for extension extendee
+ 0, // [0:3] is the sub-list for field type_name
+}
+
+func init() { file_common_api_keeper_proto_init() }
+func file_common_api_keeper_proto_init() {
+ if File_common_api_keeper_proto != nil {
+ return
+ }
+ if !protoimpl.UnsafeEnabled {
+ file_common_api_keeper_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*RegisterRequest); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_common_api_keeper_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*RegisterResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_common_api_keeper_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*LoginRequest); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_common_api_keeper_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*LoginResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_common_api_keeper_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*VaultItem); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_common_api_keeper_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*ListVaultItemsRequest); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_common_api_keeper_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*ListVaultItemsResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_common_api_keeper_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SetVaultItemRequest); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_common_api_keeper_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SetVaultItemResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: file_common_api_keeper_proto_rawDesc,
+ NumEnums: 1,
+ NumMessages: 9,
+ NumExtensions: 0,
+ NumServices: 1,
+ },
+ GoTypes: file_common_api_keeper_proto_goTypes,
+ DependencyIndexes: file_common_api_keeper_proto_depIdxs,
+ EnumInfos: file_common_api_keeper_proto_enumTypes,
+ MessageInfos: file_common_api_keeper_proto_msgTypes,
+ }.Build()
+ File_common_api_keeper_proto = out.File
+ file_common_api_keeper_proto_rawDesc = nil
+ file_common_api_keeper_proto_goTypes = nil
+ file_common_api_keeper_proto_depIdxs = nil
+}
diff --git a/common/grpc/keeper_grpc.pb.go b/common/grpc/keeper_grpc.pb.go
new file mode 100644
index 0000000..36ebf8f
--- /dev/null
+++ b/common/grpc/keeper_grpc.pb.go
@@ -0,0 +1,220 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.3.0
+// - protoc v3.12.4
+// source: common/api/keeper.proto
+
+package grpc
+
+import (
+ context "context"
+ grpc "google.golang.org/grpc"
+ codes "google.golang.org/grpc/codes"
+ status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.32.0 or later.
+const _ = grpc.SupportPackageIsVersion7
+
+const (
+ GophKeeperService_Register_FullMethodName = "/common.grpc.GophKeeperService/Register"
+ GophKeeperService_Login_FullMethodName = "/common.grpc.GophKeeperService/Login"
+ GophKeeperService_ListVaultItems_FullMethodName = "/common.grpc.GophKeeperService/ListVaultItems"
+ GophKeeperService_SetVaultItem_FullMethodName = "/common.grpc.GophKeeperService/SetVaultItem"
+)
+
+// GophKeeperServiceClient is the client API for GophKeeperService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type GophKeeperServiceClient interface {
+ Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
+ Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error)
+ ListVaultItems(ctx context.Context, in *ListVaultItemsRequest, opts ...grpc.CallOption) (*ListVaultItemsResponse, error)
+ SetVaultItem(ctx context.Context, in *SetVaultItemRequest, opts ...grpc.CallOption) (*SetVaultItemResponse, error)
+}
+
+type gophKeeperServiceClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewGophKeeperServiceClient(cc grpc.ClientConnInterface) GophKeeperServiceClient {
+ return &gophKeeperServiceClient{cc}
+}
+
+func (c *gophKeeperServiceClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
+ out := new(RegisterResponse)
+ err := c.cc.Invoke(ctx, GophKeeperService_Register_FullMethodName, in, out, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *gophKeeperServiceClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) {
+ out := new(LoginResponse)
+ err := c.cc.Invoke(ctx, GophKeeperService_Login_FullMethodName, in, out, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *gophKeeperServiceClient) ListVaultItems(ctx context.Context, in *ListVaultItemsRequest, opts ...grpc.CallOption) (*ListVaultItemsResponse, error) {
+ out := new(ListVaultItemsResponse)
+ err := c.cc.Invoke(ctx, GophKeeperService_ListVaultItems_FullMethodName, in, out, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *gophKeeperServiceClient) SetVaultItem(ctx context.Context, in *SetVaultItemRequest, opts ...grpc.CallOption) (*SetVaultItemResponse, error) {
+ out := new(SetVaultItemResponse)
+ err := c.cc.Invoke(ctx, GophKeeperService_SetVaultItem_FullMethodName, in, out, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// GophKeeperServiceServer is the server API for GophKeeperService service.
+// All implementations must embed UnimplementedGophKeeperServiceServer
+// for forward compatibility
+type GophKeeperServiceServer interface {
+ Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
+ Login(context.Context, *LoginRequest) (*LoginResponse, error)
+ ListVaultItems(context.Context, *ListVaultItemsRequest) (*ListVaultItemsResponse, error)
+ SetVaultItem(context.Context, *SetVaultItemRequest) (*SetVaultItemResponse, error)
+ mustEmbedUnimplementedGophKeeperServiceServer()
+}
+
+// UnimplementedGophKeeperServiceServer must be embedded to have forward compatible implementations.
+type UnimplementedGophKeeperServiceServer struct {
+}
+
+func (UnimplementedGophKeeperServiceServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method Register not implemented")
+}
+func (UnimplementedGophKeeperServiceServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method Login not implemented")
+}
+func (UnimplementedGophKeeperServiceServer) ListVaultItems(context.Context, *ListVaultItemsRequest) (*ListVaultItemsResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method ListVaultItems not implemented")
+}
+func (UnimplementedGophKeeperServiceServer) SetVaultItem(context.Context, *SetVaultItemRequest) (*SetVaultItemResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method SetVaultItem not implemented")
+}
+func (UnimplementedGophKeeperServiceServer) mustEmbedUnimplementedGophKeeperServiceServer() {}
+
+// UnsafeGophKeeperServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to GophKeeperServiceServer will
+// result in compilation errors.
+type UnsafeGophKeeperServiceServer interface {
+ mustEmbedUnimplementedGophKeeperServiceServer()
+}
+
+func RegisterGophKeeperServiceServer(s grpc.ServiceRegistrar, srv GophKeeperServiceServer) {
+ s.RegisterService(&GophKeeperService_ServiceDesc, srv)
+}
+
+func _GophKeeperService_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(RegisterRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(GophKeeperServiceServer).Register(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: GophKeeperService_Register_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(GophKeeperServiceServer).Register(ctx, req.(*RegisterRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _GophKeeperService_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(LoginRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(GophKeeperServiceServer).Login(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: GophKeeperService_Login_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(GophKeeperServiceServer).Login(ctx, req.(*LoginRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _GophKeeperService_ListVaultItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(ListVaultItemsRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(GophKeeperServiceServer).ListVaultItems(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: GophKeeperService_ListVaultItems_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(GophKeeperServiceServer).ListVaultItems(ctx, req.(*ListVaultItemsRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _GophKeeperService_SetVaultItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(SetVaultItemRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(GophKeeperServiceServer).SetVaultItem(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: GophKeeperService_SetVaultItem_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(GophKeeperServiceServer).SetVaultItem(ctx, req.(*SetVaultItemRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+// GophKeeperService_ServiceDesc is the grpc.ServiceDesc for GophKeeperService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var GophKeeperService_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "common.grpc.GophKeeperService",
+ HandlerType: (*GophKeeperServiceServer)(nil),
+ Methods: []grpc.MethodDesc{
+ {
+ MethodName: "Register",
+ Handler: _GophKeeperService_Register_Handler,
+ },
+ {
+ MethodName: "Login",
+ Handler: _GophKeeperService_Login_Handler,
+ },
+ {
+ MethodName: "ListVaultItems",
+ Handler: _GophKeeperService_ListVaultItems_Handler,
+ },
+ {
+ MethodName: "SetVaultItem",
+ Handler: _GophKeeperService_SetVaultItem_Handler,
+ },
+ },
+ Streams: []grpc.StreamDesc{},
+ Metadata: "common/api/keeper.proto",
+}
diff --git a/common/model/vault/item.go b/common/model/vault/item.go
new file mode 100644
index 0000000..3a80b90
--- /dev/null
+++ b/common/model/vault/item.go
@@ -0,0 +1,22 @@
+package vault
+
+const (
+ Unknown ItemType = iota
+ Password
+ Card
+ Text
+ Binary
+ BinaryLarge
+)
+
+type ItemType int32
+
+type Item struct {
+ ID string
+ Name string
+ Type ItemType
+ Value []byte
+ ServerUpdatedAt int64
+ ClientUpdatedAt int64
+ IsDeleted bool
+}
diff --git a/docs/sheme.md b/docs/sheme.md
new file mode 100644
index 0000000..12f6b9a
--- /dev/null
+++ b/docs/sheme.md
@@ -0,0 +1,29 @@
+# Схема обмена данными в GophKeeper
+
+GophKeeper представляет собой клиент-серверную систему с возможностью автономной работы клиента после успешной регистрации на сервере.
+
+Для нового пользователя:
+- Пользователь получает клиент под необходимую ему платформу.
+- Пользователь проходит процедуру первичной регистрации: вводит email и пароль в клиенте.
+- Пароль пользователя преобразуется в два хеша (Argon2): encryption key и auth hash, после чего удаляется.
+- Encryption key и auth hash сохраняются в хранилище клиента.
+- Auth hash и email отправляются на сервер, auth hash преобразуется в хеш (Argon2) auth key, который сохраняется вместе с email в БД.
+
+Для существующего пользователя:
+- Пользователь проходит процедуру аутентификации.
+ - При необходимости (нет токена и encryption key или auth hash и encryption key): вводит email и пароль в клиенте.
+ - Пароль пользователя преобразуется в два хеша (Argon2): encryption key и auth hash, после чего удаляется.
+ - Encryption key и auth hash сохраняются в хранилище клиента.
+ - Auth hash и email отправляются на сервер, при успехе - пользователь получает токен и сохраняет его в хранилище.
+- Пользователь добавляет/изменяет данные - данные шифруются (chacha20+poly1305) и сохраняются в локальное хранилище.
+- Клиент синхронизирует данные с сервером.
+
+Безопасность:
+- Пароль пользователя не хранится нигде.
+- Данные пользователя (пароли, карты, тексты и т.п.) хранятся на сервере и клиенте в зашифрованном виде.
+- Encryption key хранится только у пользователя (и может быть получен из пароля). Даже владелец сервера синхронизации не сможет расшифровать данные, если захочет.
+- Производный от него auth hash, который используется для аутентификации на сервере, не хранится напрямую на сервере, а сохраняется его хеш - auth key.
+- Auth key использует в качестве соли случайные числа для усложения перебора по таблице при получении доступа к БД.
+
+
+
\ No newline at end of file
diff --git a/docs/sheme.png b/docs/sheme.png
new file mode 100644
index 0000000..337af00
Binary files /dev/null and b/docs/sheme.png differ
diff --git a/gophkeeper.png b/gophkeeper.png
new file mode 100644
index 0000000..3248101
Binary files /dev/null and b/gophkeeper.png differ
diff --git a/pkg/e/e.go b/pkg/e/e.go
new file mode 100644
index 0000000..70bb010
--- /dev/null
+++ b/pkg/e/e.go
@@ -0,0 +1,18 @@
+// Package e provides a helper function to work with errors.
+package e
+
+import (
+ "fmt"
+)
+
+// Wrap returns a new wrapped error: combination of msg and err.
+//
+// If err is not nil the returned error will implement an Unwrap method returning err.
+// If err is nil the returned error will be nil.
+func Wrap(msg string, err error) error {
+ if err == nil {
+ return nil
+ }
+
+ return fmt.Errorf("%s: %w", msg, err)
+}
diff --git a/pkg/go.mod b/pkg/go.mod
index 661b5ef..12d0db5 100644
--- a/pkg/go.mod
+++ b/pkg/go.mod
@@ -1,3 +1,14 @@
module github.com/Karzoug/goph_keeper/pkg
-go 1.20
+go 1.21
+
+require (
+ github.com/fatih/color v1.15.0
+ github.com/goccy/go-json v0.10.2
+)
+
+require (
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.17 // indirect
+ golang.org/x/sys v0.11.0 // indirect
+)
diff --git a/pkg/go.sum b/pkg/go.sum
new file mode 100644
index 0000000..bca0049
--- /dev/null
+++ b/pkg/go.sum
@@ -0,0 +1,12 @@
+github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
+github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+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.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=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/pkg/logger/slog/discard/discard.go b/pkg/logger/slog/discard/discard.go
new file mode 100644
index 0000000..5b92182
--- /dev/null
+++ b/pkg/logger/slog/discard/discard.go
@@ -0,0 +1,39 @@
+package discard
+
+import (
+ "context"
+
+ "log/slog"
+)
+
+// NewDiscardLogger creates a new logger that discards all messages.
+func NewDiscardLogger() *slog.Logger {
+ return slog.New(NewDiscardHandler())
+}
+
+type discardHandler struct{}
+
+// NewDiscardHandler creates a new handler that discards all messages produced by a Logger.
+func NewDiscardHandler() *discardHandler {
+ return &discardHandler{}
+}
+
+// Handle handles the Record: do nothing and return nil.
+func (h *discardHandler) Handle(_ context.Context, _ slog.Record) error {
+ return nil
+}
+
+// WithAttrs does nothing and returns the old handler.
+func (h *discardHandler) WithAttrs(_ []slog.Attr) slog.Handler {
+ return h
+}
+
+// WithGroup does nothing and returns the old handler.
+func (h *discardHandler) WithGroup(_ string) slog.Handler {
+ return h
+}
+
+// Enabled reports whether the handler handles records at the given level: returns always false.
+func (h *discardHandler) Enabled(_ context.Context, _ slog.Level) bool {
+ return false
+}
diff --git a/pkg/logger/slog/pretty/pretty.go b/pkg/logger/slog/pretty/pretty.go
new file mode 100644
index 0000000..62dd076
--- /dev/null
+++ b/pkg/logger/slog/pretty/pretty.go
@@ -0,0 +1,105 @@
+package pretty
+
+import (
+ "context"
+ "io"
+ stdLog "log"
+
+ "log/slog"
+
+ "github.com/fatih/color"
+ "github.com/goccy/go-json"
+)
+
+// HandlerOptions are options for a Handler.
+type HandlerOptions struct {
+ SlogOpts *slog.HandlerOptions
+}
+
+// Handler is a slog Handler with pretty formatting.
+type Handler struct {
+ // opts HandlerOptions
+ slog.Handler
+ l *stdLog.Logger
+ attrs []slog.Attr
+}
+
+// NewPrettyHandler returns a new slog Handler with pretty formatting.
+func (opts HandlerOptions) NewPrettyHandler(out io.Writer) *Handler {
+ h := &Handler{
+ Handler: slog.NewJSONHandler(out, opts.SlogOpts),
+ l: stdLog.New(out, "", 0),
+ }
+
+ return h
+}
+
+// Handle writes pretty formatting Record to out Writer.
+func (h *Handler) Handle(_ context.Context, r slog.Record) error {
+ level := r.Level.String() + ":"
+
+ switch r.Level {
+ case slog.LevelDebug:
+ level = color.MagentaString(level)
+ case slog.LevelInfo:
+ level = color.BlueString(level)
+ case slog.LevelWarn:
+ level = color.YellowString(level)
+ case slog.LevelError:
+ level = color.RedString(level)
+ }
+
+ fields := make(map[string]interface{}, r.NumAttrs())
+
+ r.Attrs(func(a slog.Attr) bool {
+ fields[a.Key] = a.Value.Any()
+
+ return true
+ })
+
+ for _, a := range h.attrs {
+ fields[a.Key] = a.Value.Any()
+ }
+
+ var b []byte
+ var err error
+
+ if len(fields) > 0 {
+ b, err = json.MarshalIndent(fields, "", " ")
+ if err != nil {
+ return err
+ }
+ }
+
+ timeStr := r.Time.Format("[15:05:05.000]")
+ msg := color.CyanString(r.Message)
+
+ h.l.Println(
+ timeStr,
+ level,
+ msg,
+ color.WhiteString(string(b)),
+ )
+
+ return nil
+}
+
+// WithAttrs returns a new Handler whose attributes consist of
+// both the receiver's attributes and the arguments.
+func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ return &Handler{
+ Handler: h.Handler,
+ l: h.l,
+ attrs: attrs,
+ }
+}
+
+// WithGroup returns a new Handler with the given group appended to
+// the receiver's existing groups.
+func (h *Handler) WithGroup(name string) slog.Handler {
+ // TODO: implement
+ return &Handler{
+ Handler: h.Handler.WithGroup(name),
+ l: h.l,
+ }
+}
diff --git a/pkg/logger/slog/sl/sl.go b/pkg/logger/slog/sl/sl.go
new file mode 100644
index 0000000..48fa68f
--- /dev/null
+++ b/pkg/logger/slog/sl/sl.go
@@ -0,0 +1,12 @@
+package sl
+
+import (
+ "log/slog"
+)
+
+func Error(err error) slog.Attr {
+ return slog.Attr{
+ Key: "error",
+ Value: slog.StringValue(err.Error()),
+ }
+}
diff --git a/server/assets/mail/template.go b/server/assets/mail/template.go
new file mode 100644
index 0000000..699cc3c
--- /dev/null
+++ b/server/assets/mail/template.go
@@ -0,0 +1,34 @@
+package mail
+
+import (
+ _ "embed"
+ htemplate "html/template"
+ template "text/template"
+
+ "github.com/Karzoug/goph_keeper/server/internal/service/task"
+)
+
+var (
+ //go:embed verification/welcome.html
+ welcomeVerificationHTMLTemplateBody string
+ //go:embed verification/welcome.txt
+ welcomeVerificationTextTemplateBody string
+
+ Templates = map[string]Template{
+ task.TypeWelcomeVerificationEmail: {
+ HTMLTemplate: htemplate.Must(htemplate.New("welcome_verification_email_html").Parse(welcomeVerificationHTMLTemplateBody)),
+ TextTemplate: template.Must(template.New("welcome_verification_email_text").Parse(welcomeVerificationTextTemplateBody)),
+ Subject: "Confirm your GophKeeper account",
+ FromEmail: "noreply@gophkeeper.com",
+ FromName: "GophKeeper team",
+ },
+ }
+)
+
+type Template struct {
+ HTMLTemplate *htemplate.Template
+ TextTemplate *template.Template
+ Subject string
+ FromEmail string
+ FromName string
+}
diff --git a/server/assets/mail/verification/welcome.html b/server/assets/mail/verification/welcome.html
new file mode 100644
index 0000000..d01024c
--- /dev/null
+++ b/server/assets/mail/verification/welcome.html
@@ -0,0 +1,174 @@
+
+
+
+
+
+ Confirm your GophKeeper account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Confirm Your Email Address
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ Thanks for getting started with our GophKeeper!
+ Copy and paste the following code in the app to activate your account:
+ {{ . }}
+ |
+
+
+
+ If you didn't create an account with GophKeeper, you can safely delete this email.
+ |
+
+
+
+ Cheers, GophKeeper
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ You received this email because we received a request for registation for your account. If you didn't request registration you can safely delete this email.
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/server/assets/mail/verification/welcome.txt b/server/assets/mail/verification/welcome.txt
new file mode 100644
index 0000000..cabce06
--- /dev/null
+++ b/server/assets/mail/verification/welcome.txt
@@ -0,0 +1,8 @@
+Thanks for getting started with our GophKeeper!
+
+Copy and paste the following code in the app to activate your account: {{ . }}
+
+If you didn't create an account with GophKeeper, you can safely delete this email.
+
+Cheers,
+GophKeeper
\ No newline at end of file
diff --git a/server/build/.env b/server/build/.env
new file mode 100644
index 0000000..9fba566
--- /dev/null
+++ b/server/build/.env
@@ -0,0 +1,4 @@
+GRPC_PORT='8080'
+PG_USER='gopher'
+PG_PASSWORD='password'
+PG_DB='goph_keeper'
\ No newline at end of file
diff --git a/server/build/dev.env b/server/build/dev.env
new file mode 100644
index 0000000..65e1f7f
--- /dev/null
+++ b/server/build/dev.env
@@ -0,0 +1,11 @@
+GOPHKEEPER_ENV=dev
+GOPHKEEPER_SERVICE_STORAGE_URI='postgres://gopher:password@postgres:5432/goph_keeper?sslmode=disable'
+GOPHKEEPER_RTASK_STORAGE_URI='redis://redis:6379/1'
+GOPHKEEPER_SERVICE_MAIL_CACHE_URI='redis://redis:6379/2'
+GOPHKEEPER_SERVICE_AUTH_CACHE_URI='redis://redis:6379/3'
+GOPHKEEPER_SMTP_HOST=mailpit
+GOPHKEEPER_SMTP_PORT=1025
+GOPHKEEPER_GRPC_HOST=''
+GOPHKEEPER_GRPC_PORT='8080'
+GOPHKEEPER_GRPC_CERT_FILE_NAME='cert.pem'
+GOPHKEEPER_GRPC_KEY_FILE_NAME='key.pem'
\ No newline at end of file
diff --git a/server/build/docker-compose.yml b/server/build/docker-compose.yml
new file mode 100644
index 0000000..2056ebd
--- /dev/null
+++ b/server/build/docker-compose.yml
@@ -0,0 +1,60 @@
+version: '3.9'
+
+services:
+ gophermart-postgres:
+ depends_on:
+ - redis
+ - migrate
+ - mailpit
+ build:
+ context: ./../
+ dockerfile: ./build/server.Dockerfile
+ container_name: goph_keeper_server
+ env_file:
+ - dev.env
+ - dev_secret_key.env
+ ports:
+ - ${GRPC_PORT}:${GRPC_PORT}
+ deploy:
+ restart_policy:
+ condition: on-failure
+ redis:
+ image: redis
+ restart: always
+ ports:
+ - '6379:6379'
+ command: redis-server
+ postgres:
+ image: postgres
+ environment:
+ POSTGRES_DB: $PG_DB
+ POSTGRES_USER: $PG_USER
+ POSTGRES_PASSWORD: $PG_PASSWORD
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres-docker:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready --dbname postgres://${PG_USER}:${PG_PASSWORD}@postgres:5432/${PG_DB}?sslmode=disable"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ migrate:
+ image: migrate/migrate
+ volumes:
+ - ./../migrations/postgres:/migrations
+ command: ["-path", "/migrations", "-database", "postgres://${PG_USER}:${PG_PASSWORD}@postgres:5432/${PG_DB}?sslmode=disable", "up"]
+ links:
+ - postgres
+ depends_on:
+ postgres:
+ condition: service_healthy
+ mailpit:
+ image: axllent/mailpit
+ ports:
+ - '1025:1025'
+ - '8025:8025'
+ restart: unless-stopped
+
+volumes:
+ postgres-docker:
\ No newline at end of file
diff --git a/server/build/server.Dockerfile b/server/build/server.Dockerfile
new file mode 100644
index 0000000..09c7dea
--- /dev/null
+++ b/server/build/server.Dockerfile
@@ -0,0 +1,30 @@
+# syntax=docker/dockerfile:1
+
+# Build the application from source
+FROM golang:1.21 AS build-stage
+
+WORKDIR /app
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+RUN CGO_ENABLED=0 cd ./cmd && go build -buildvcs=false -o /goph_keeper_server
+
+# Run the tests in the container
+FROM build-stage AS run-test-stage
+RUN go test -v ./...
+
+# Deploy the application binary into a lean image
+FROM debian:trixie-slim AS build-release-stage
+
+WORKDIR /
+
+COPY --from=build-stage /goph_keeper_server /goph_keeper_server
+COPY --from=build-stage ./app/build/key.pem /key.pem
+COPY --from=build-stage ./app/build/cert.pem /cert.pem
+
+EXPOSE $GOPHKEEPER_GRPC_PORT
+
+CMD ["/goph_keeper_server"]
\ No newline at end of file
diff --git a/server/cmd/builder.go b/server/cmd/builder.go
new file mode 100644
index 0000000..6827030
--- /dev/null
+++ b/server/cmd/builder.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+ "os"
+ "reflect"
+
+ "log/slog"
+
+ "github.com/caarlos0/env/v9"
+
+ "github.com/Karzoug/goph_keeper/pkg/logger/slog/pretty"
+ "github.com/Karzoug/goph_keeper/server/internal/config"
+)
+
+func buildConfig() (*config.Config, error) {
+ cfg := new(config.Config)
+
+ opts := env.Options{
+ Prefix: "GOPHKEEPER_",
+ FuncMap: map[reflect.Type]env.ParserFunc{
+ reflect.TypeOf(cfg.Env): config.EnvTypeParserFunc},
+ }
+
+ return cfg, env.ParseWithOptions(cfg, opts)
+}
+
+func buildLogger(env config.EnvType) *slog.Logger {
+ var log *slog.Logger
+
+ switch env {
+ case config.EnvDevelopment:
+ opts := pretty.HandlerOptions{
+ SlogOpts: &slog.HandlerOptions{
+ Level: slog.LevelDebug,
+ },
+ }
+
+ handler := opts.NewPrettyHandler(os.Stdout)
+ return slog.New(handler)
+ case config.EnvProduction:
+ log = slog.New(
+ slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
+ )
+ default:
+ log = slog.New(
+ slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
+ )
+ }
+
+ return log
+}
diff --git a/server/cmd/main.go b/server/cmd/main.go
new file mode 100644
index 0000000..ec69437
--- /dev/null
+++ b/server/cmd/main.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+ "context"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "log/slog"
+
+ "github.com/Karzoug/goph_keeper/pkg/logger/slog/sl"
+ "github.com/Karzoug/goph_keeper/server/internal/app"
+)
+
+var (
+ buildVersion = "N/A"
+ buildDate = "N/A"
+)
+
+func main() {
+ cfg, err := buildConfig()
+ if err != nil {
+ log.Fatal("parse config error: ", err)
+ }
+
+ logger := buildLogger(cfg.Env)
+
+ logger.Info(
+ "starting goph-keeper server",
+ slog.String("env", cfg.Env.String()),
+ slog.String("build version", buildVersion),
+ slog.String("build date", buildDate),
+ )
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
+ defer stop()
+
+ if err := app.Run(ctx, cfg, logger); err != nil {
+ logger.Error("application stopped with error", sl.Error(err))
+ os.Exit(1)
+ }
+}
diff --git a/server/go.mod b/server/go.mod
index 21ca283..64b871b 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -1,3 +1,64 @@
module github.com/Karzoug/goph_keeper/server
-go 1.20
+go 1.21
+
+require (
+ github.com/Karzoug/goph_keeper/common v0.7.1
+ github.com/Karzoug/goph_keeper/pkg v0.4.0
+)
+
+require (
+ github.com/caarlos0/env/v9 v9.0.0
+ github.com/goccy/go-json v0.10.2
+ github.com/hibiken/asynq v0.24.1
+ github.com/jackc/pgx/v5 v5.4.3
+ github.com/matthewhartstonge/argon2 v0.3.3
+ github.com/redis/go-redis/v9 v9.0.5
+ github.com/rs/xid v1.5.0
+ github.com/stretchr/testify v1.8.4
+ github.com/xhit/go-simple-mail/v2 v2.15.0
+ golang.org/x/net v0.14.0
+ golang.org/x/sync v0.3.0
+ google.golang.org/grpc v1.57.0
+ modernc.org/sqlite v1.25.0
+)
+
+require (
+ github.com/cespare/xxhash/v2 v2.2.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/fatih/color v1.15.0 // indirect
+ github.com/go-test/deep v1.1.0 // indirect
+ github.com/golang/protobuf v1.5.3 // indirect
+ github.com/google/uuid v1.3.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+ github.com/jackc/puddle/v2 v2.2.1 // indirect
+ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.19 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/robfig/cron/v3 v3.0.1 // indirect
+ github.com/spf13/cast v1.5.1 // indirect
+ github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
+ golang.org/x/crypto v0.12.0 // indirect
+ golang.org/x/mod v0.12.0 // indirect
+ golang.org/x/sys v0.11.0 // indirect
+ golang.org/x/text v0.12.0 // indirect
+ golang.org/x/time v0.3.0 // indirect
+ golang.org/x/tools v0.12.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 // indirect
+ google.golang.org/protobuf v1.31.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ lukechampine.com/uint128 v1.3.0 // indirect
+ modernc.org/cc/v3 v3.41.0 // indirect
+ modernc.org/ccgo/v3 v3.16.14 // indirect
+ modernc.org/libc v1.24.1 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.6.0 // indirect
+ modernc.org/opt v0.1.3 // indirect
+ modernc.org/strutil v1.1.3 // indirect
+ modernc.org/token v1.1.0 // indirect
+)
diff --git a/server/go.sum b/server/go.sum
new file mode 100644
index 0000000..2bc893d
--- /dev/null
+++ b/server/go.sum
@@ -0,0 +1,187 @@
+github.com/Karzoug/goph_keeper/common v0.7.1 h1:lIM93VgdvjwfYuq65j45YPY/aeYkXALkmXrsIyElprE=
+github.com/Karzoug/goph_keeper/common v0.7.1/go.mod h1:LkSZ9pS4W6GdXG8ARiFkCgzugvJbfoLNdvdrThzUKII=
+github.com/Karzoug/goph_keeper/pkg v0.4.0 h1:ZQnMv8gLTfL3hxmxVc/gUt3ZT69oYGj4p3QMfG+d+Q8=
+github.com/Karzoug/goph_keeper/pkg v0.4.0/go.mod h1:DXUAGvjNBudmXwmAYrmEfqB5sULFI4/vRBmr4BZoZzg=
+github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
+github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
+github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
+github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc=
+github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
+github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
+github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
+github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
+github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+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/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
+github.com/google/uuid v1.2.0/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/hibiken/asynq v0.24.1 h1:+5iIEAyA9K/lcSPvx3qoPtsKJeKI5u9aOIvUmSsazEw=
+github.com/hibiken/asynq v0.24.1/go.mod h1:u5qVeSbrnfT+vtG5Mq8ZPzQu/BmCKMHvTGb91uy9Tts=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
+github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
+github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
+github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/matthewhartstonge/argon2 v0.3.3 h1:38/hupgfzqO2UGxqXqmSqErE8KJvQnIxWWg7IXUqWgQ=
+github.com/matthewhartstonge/argon2 v0.3.3/go.mod h1:W2fhVs3+4FGxqDiap9SxxwNF/0SOVYcITpqDZe8RrhY=
+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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+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/redis/go-redis/v9 v9.0.3/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
+github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o=
+github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
+github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
+github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
+github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+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/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
+github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
+github.com/xhit/go-simple-mail/v2 v2.15.0 h1:qMXeqcZErUW/Dw6EXxmPuxHzVI8MdxWnEnu2xcisohU=
+github.com/xhit/go-simple-mail/v2 v2.15.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
+go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
+golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
+golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/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-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
+golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
+golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss=
+golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM=
+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=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 h1:2FZP5XuJY9zQyGM5N0rtovnoXjiMUEIUMvw0m9wlpLc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o=
+google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
+google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
+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.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
+lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
+modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
+modernc.org/ccgo/v3 v3.16.14 h1:af6KNtFgsVmnDYrWk3PQCS9XT6BXe7o3ZFJKkIKvXNQ=
+modernc.org/ccgo/v3 v3.16.14/go.mod h1:mPDSujUIaTNWQSG4eqKw+atqLOEbma6Ncsa94WbC9zo=
+modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
+modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
+modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
+modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
+modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
+modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
+modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA=
+modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
+modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
+modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
+modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
+modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
+modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
diff --git a/server/internal/app/app.go b/server/internal/app/app.go
new file mode 100644
index 0000000..3d9030f
--- /dev/null
+++ b/server/internal/app/app.go
@@ -0,0 +1,143 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "log/slog"
+
+ "golang.org/x/sync/errgroup"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ "github.com/Karzoug/goph_keeper/pkg/logger/slog/sl"
+ "github.com/Karzoug/goph_keeper/server/internal/config"
+ scfg "github.com/Karzoug/goph_keeper/server/internal/config/service"
+ "github.com/Karzoug/goph_keeper/server/internal/config/storage"
+ "github.com/Karzoug/goph_keeper/server/internal/delivery/grpc"
+ rtasks "github.com/Karzoug/goph_keeper/server/internal/delivery/rtask"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/mail/smtp"
+ rtaskc "github.com/Karzoug/goph_keeper/server/internal/repository/rtask"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/storage/postgres"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/storage/redis"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/storage/sqlite"
+ "github.com/Karzoug/goph_keeper/server/internal/service"
+)
+
+func Run(ctx context.Context, cfg *config.Config, logger *slog.Logger) error {
+ const op = "app run"
+
+ serviceStorage, err := buildServiceStorage(ctx, cfg.Service.Storage)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ defer serviceStorage.Close()
+ logger.Info("app run: service storage created")
+
+ rtaskClient, err := rtaskc.New(cfg.RTask.Storage.URI, logger)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ logger.Info("app run: client for redis task manager created")
+
+ smtpClient, err := smtp.New(cfg.SMTP)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ logger.Info("app run: smtp client created")
+
+ opts, closeFns, err := buildServiceOptions(cfg.Service)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ defer func() {
+ for _, fn := range closeFns {
+ if err := fn(); err != nil {
+ logger.Error("close before app stop failed", sl.Error(err))
+ }
+ }
+ }()
+ opts = append(opts, service.WithSLogger(logger))
+
+ service, err := service.New(cfg.Service, serviceStorage, rtaskClient, smtpClient, opts...)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ logger.Info("app run: service created")
+
+ grpcServer, err := grpc.New(cfg.GRPC, service, logger)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ logger.Info("app run: grpc server created")
+
+ rtaskServer, err := rtasks.New(cfg.RTask, service, logger)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ logger.Info("app run: server for redis task manager created")
+
+ g := new(errgroup.Group)
+
+ g.Go(func() error {
+ return grpcServer.Run(ctx)
+ })
+
+ g.Go(func() error {
+ return rtaskServer.Run(ctx)
+ })
+
+ if err := g.Wait(); err != nil {
+ return e.Wrap(op, err)
+ }
+
+ return nil
+}
+
+func buildServiceStorage(ctx context.Context, cfg storage.Config) (service.Storage, error) {
+ switch {
+ case strings.HasPrefix(cfg.URI, postgres.URIPreffix):
+ return postgres.New(ctx, cfg)
+ case strings.HasPrefix(cfg.URI, sqlite.URIPreffix):
+ return sqlite.New(ctx, cfg)
+ case strings.HasPrefix(cfg.URI, "grpc://"):
+ panic("not implemented")
+ default:
+ return nil, errors.New("unknown storage type")
+ }
+}
+
+func buildServiceOptions(cfg scfg.Config) ([]service.Option, []func() error, error) {
+ opts := make([]service.Option, 0)
+ closeFns := make([]func() error, 0)
+
+ if len(cfg.AuthCache.URI) != 0 {
+ ac, err := buildServiceCache(cfg.AuthCache)
+ if err != nil {
+ return nil, nil, err
+ } else {
+ opts = append(opts, service.WithAuthCache(ac))
+ closeFns = append(closeFns, ac.Close)
+ }
+ }
+ if len(cfg.MailCache.URI) != 0 {
+ mc, err := buildServiceCache(cfg.MailCache)
+ if err != nil {
+ return nil, nil, err
+ } else {
+ opts = append(opts, service.WithMailCache(mc))
+ closeFns = append(closeFns, mc.Close)
+ }
+ }
+
+ return opts, closeFns, nil
+}
+
+func buildServiceCache(cfg storage.Config) (service.KvStorage, error) {
+ switch {
+ case strings.HasPrefix(cfg.URI, redis.URIPreffix):
+ return redis.New(cfg)
+ default:
+ return nil, errors.New("unknown storage type")
+ }
+}
diff --git a/server/internal/config/config.go b/server/internal/config/config.go
new file mode 100644
index 0000000..cbc7bd5
--- /dev/null
+++ b/server/internal/config/config.go
@@ -0,0 +1,22 @@
+package config
+
+import (
+ "github.com/Karzoug/goph_keeper/server/internal/config/grpc"
+ "github.com/Karzoug/goph_keeper/server/internal/config/rtask"
+ "github.com/Karzoug/goph_keeper/server/internal/config/service"
+ "github.com/Karzoug/goph_keeper/server/internal/config/smtp"
+)
+
+// Config is a configuration for GophKeeper server.
+type Config struct {
+ // Env is a environment type (production or development).
+ Env EnvType `env:"ENV" envDefault:"production"`
+ // GRPC is a configuration for gRPC server.
+ GRPC grpc.Config `envPrefix:"GRPC_"`
+ // Service is a configuration for service layer.
+ Service service.Config `envPrefix:"SERVICE_"`
+ // RTask is a configuration for redis task manager.
+ RTask rtask.Config `envPrefix:"RTASK_"`
+ // SMTP is a configuration for SMTP server.
+ SMTP smtp.Config `envPrefix:"SMTP_"`
+}
diff --git a/server/internal/config/envType.go b/server/internal/config/envType.go
new file mode 100644
index 0000000..c71454a
--- /dev/null
+++ b/server/internal/config/envType.go
@@ -0,0 +1,48 @@
+package config
+
+import (
+ "errors"
+ "strings"
+)
+
+const (
+ // EnvDevelopment is a constant defining the development environment.
+ EnvProduction EnvType = iota
+ // EnvProduction is a constant defining the production environment.
+ EnvDevelopment
+)
+
+const (
+ envProductionString = "production"
+ envDevelopmentString = "development"
+ envUnknownString = "unknown"
+)
+
+// ErrUnknownEnv is an error returned when the environment is unknown.
+var ErrUnknownEnv = errors.New("unknown environment mode")
+
+// EnvType is a type of environment (production or development).
+type EnvType int8
+
+// String returns the string representation of the environment type variable.
+func (e EnvType) String() string {
+ switch e {
+ case EnvDevelopment:
+ return envDevelopmentString
+ case EnvProduction:
+ return envProductionString
+ default:
+ return envUnknownString
+ }
+}
+
+// EnvTypeParserFunc is a function that parses the environment variable string.
+var EnvTypeParserFunc = func(v string) (interface{}, error) {
+ switch {
+ case strings.HasPrefix(v, "dev"):
+ return EnvDevelopment, nil
+ case strings.HasPrefix(v, "prod"):
+ return EnvProduction, nil
+ }
+ return nil, ErrUnknownEnv
+}
diff --git a/server/internal/config/grpc/config.go b/server/internal/config/grpc/config.go
new file mode 100644
index 0000000..8667170
--- /dev/null
+++ b/server/internal/config/grpc/config.go
@@ -0,0 +1,12 @@
+package grpc
+
+type Config struct {
+ Host string `env:"HOST"`
+ Port string `env:"PORT,notEmpty" envDefault:"8080"`
+ CertFileName string `env:"CERT_FILE_NAME"`
+ KeyFileName string `env:"KEY_FILE_NAME"`
+}
+
+func (cfg Config) Address() string {
+ return cfg.Host + ":" + cfg.Port
+}
diff --git a/server/internal/config/rtask/config.go b/server/internal/config/rtask/config.go
new file mode 100644
index 0000000..a9d6085
--- /dev/null
+++ b/server/internal/config/rtask/config.go
@@ -0,0 +1,13 @@
+package rtask
+
+import "github.com/Karzoug/goph_keeper/server/internal/config/storage"
+
+type Config struct {
+ // Maximum number of concurrent processing of tasks.
+ //
+ // If set to a zero or negative value, will be overwrited by the value
+ // to the number of CPUs usable by the current process.
+ Concurrency int `env:"CONCURRENCY" envDefault:"0"`
+ // Storage is a configuration for storage (redis).
+ Storage storage.Config `envPrefix:"STORAGE_"`
+}
diff --git a/server/internal/config/service/config.go b/server/internal/config/service/config.go
new file mode 100644
index 0000000..e855b5e
--- /dev/null
+++ b/server/internal/config/service/config.go
@@ -0,0 +1,26 @@
+package service
+
+import (
+ "time"
+
+ "github.com/Karzoug/goph_keeper/server/internal/config/storage"
+ "github.com/Karzoug/goph_keeper/server/internal/model/auth/token"
+)
+
+type Config struct {
+ Token struct {
+ // TokenLifetime is the lifetime of the token.
+ TokenLifetime time.Duration `env:"TOKEN_LIFETIME,notEmpty" envDefault:"168h"`
+ // SecretKey is the secret key to sign token.
+ SecretKey token.SecretKey `env:"TOKEN_SECRET_KEY,notEmpty,unset"`
+ }
+ Email struct {
+ CodeLength int `env:"EMAIL_CODE_LENGTH,notEmpty" envDefault:"6"`
+ CodeLifetime time.Duration `env:"EMAIL_CODE_LIFETIME,notEmpty" envDefault:"24h"`
+ }
+ // Storage is a configuration for storage.
+ Storage storage.Config `envPrefix:"STORAGE_"`
+ StorageMaxSizeItemValue uint `envPrefix:"STORAGE_MAX_SIZE_ITEM_VALUE,notempty" envDefault:"1048576"`
+ AuthCache storage.Config `envPrefix:"AUTH_CACHE_"`
+ MailCache storage.Config `envPrefix:"MAIL_CACHE_"`
+}
diff --git a/server/internal/config/smtp/smtp.go b/server/internal/config/smtp/smtp.go
new file mode 100644
index 0000000..f561692
--- /dev/null
+++ b/server/internal/config/smtp/smtp.go
@@ -0,0 +1,12 @@
+package smtp
+
+type Config struct {
+ // Host represents the host of the SMTP server.
+ Host string `env:"HOST"`
+ // Port represents the port of the SMTP server.
+ Port int `env:"PORT,notEmpty"`
+ // Username is the username to use to authenticate to the SMTP server.
+ Username string `env:"USERNAME"`
+ // Password is the password to use to authenticate to the SMTP server.
+ Password string `env:"PASSWORD,unset"`
+}
diff --git a/server/internal/config/storage/config.go b/server/internal/config/storage/config.go
new file mode 100644
index 0000000..f9822d4
--- /dev/null
+++ b/server/internal/config/storage/config.go
@@ -0,0 +1,7 @@
+package storage
+
+type Config struct {
+ // URI is a database identifier.
+ // URI consists of a scheme, an authority, a path, a query string, and a fragment
+ URI string `env:"URI"`
+}
diff --git a/server/internal/delivery/grpc/interceptor/auth/auth.go b/server/internal/delivery/grpc/interceptor/auth/auth.go
new file mode 100644
index 0000000..0b646a8
--- /dev/null
+++ b/server/internal/delivery/grpc/interceptor/auth/auth.go
@@ -0,0 +1,78 @@
+package auth
+
+import (
+ "context"
+ "errors"
+
+ "log/slog"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+
+ gerr "github.com/Karzoug/goph_keeper/common/grpc"
+ "github.com/Karzoug/goph_keeper/pkg/logger/slog/sl"
+ "github.com/Karzoug/goph_keeper/server/internal/service"
+)
+
+type (
+ authContextKey int8
+ AuthFunc func(ctx context.Context, token string) (string, error)
+)
+
+const emailAuthCtxKey authContextKey = 0
+
+var ErrCtxEmailNotFound = errors.New("email not found in context")
+
+func AuthUnaryServerInterceptor(authFunc AuthFunc, publicMethods []string, logger *slog.Logger) grpc.UnaryServerInterceptor {
+ isPublicMethodCheckFnc := func(m string) bool {
+ for i := 0; i < len(publicMethods); i++ {
+ if m == publicMethods[i] {
+ return true
+ }
+ }
+ return false
+ }
+
+ logger = logger.With("from", "auth grpc interceptor")
+
+ return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
+ if isPublicMethodCheckFnc(info.FullMethod) {
+ return handler(ctx, req)
+ }
+
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return nil, gerr.ErrEmptyAuthData
+ }
+
+ tokenSlice := md["token"]
+ if len(tokenSlice) == 0 {
+ return nil, gerr.ErrEmptyAuthData
+ }
+
+ email, err := authFunc(ctx, tokenSlice[0])
+ if err != nil {
+ switch {
+ case errors.Is(err, service.ErrInvalidTokenFormat):
+ return nil, gerr.ErrInvalidTokenFormat
+ case errors.Is(err, service.ErrUserNeedAuthentication):
+ return nil, gerr.ErrUserNeedAuthentication
+ default:
+ logger.Error("auth user failed", sl.Error(err))
+ return nil, gerr.ErrInternal
+ }
+ }
+
+ newCtx := context.WithValue(ctx, emailAuthCtxKey, email)
+ return handler(newCtx, req)
+ }
+}
+
+func EmailFromContext(ctx context.Context) (string, error) {
+ value := ctx.Value(emailAuthCtxKey)
+ if value == nil {
+ return "", ErrCtxEmailNotFound
+ }
+
+ return value.(string), nil
+}
diff --git a/server/internal/delivery/grpc/server.go b/server/internal/delivery/grpc/server.go
new file mode 100644
index 0000000..6a639d6
--- /dev/null
+++ b/server/internal/delivery/grpc/server.go
@@ -0,0 +1,104 @@
+package grpc
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+
+ "log/slog"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+
+ pb "github.com/Karzoug/goph_keeper/common/grpc"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ gcfg "github.com/Karzoug/goph_keeper/server/internal/config/grpc"
+ "github.com/Karzoug/goph_keeper/server/internal/delivery/grpc/interceptor/auth"
+ "github.com/Karzoug/goph_keeper/server/internal/service"
+)
+
+type server struct {
+ cfg gcfg.Config
+ logger *slog.Logger
+ service *service.Service
+
+ grpcServer *grpc.Server
+ pb.UnimplementedGophKeeperServiceServer
+}
+
+func New(cfg gcfg.Config, service *service.Service, logger *slog.Logger) (*server, error) {
+ const op = "create grpc server"
+
+ publicMethods := []string{
+ pb.GophKeeperService_Register_FullMethodName,
+ pb.GophKeeperService_Login_FullMethodName,
+ }
+
+ tlsCfg, err := loadConfig(cfg.CertFileName, cfg.KeyFileName)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsCfg)),
+ grpc.UnaryInterceptor(
+ auth.AuthUnaryServerInterceptor(service.AuthUser, publicMethods, logger)))
+
+ ss := &server{
+ cfg: cfg,
+ logger: logger.With("from", "grpc server"),
+ service: service,
+ grpcServer: grpcServer,
+ }
+
+ pb.RegisterGophKeeperServiceServer(ss.grpcServer, ss)
+
+ return ss, nil
+}
+
+func (s *server) Run(ctx context.Context) error {
+ const op = "run"
+
+ s.logger.Info("running", slog.String("address", s.cfg.Address()))
+
+ idleConnsClosed := make(chan struct{})
+
+ var lc net.ListenConfig
+ listen, err := lc.Listen(ctx, "tcp", s.cfg.Address())
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ go func() {
+ <-ctx.Done()
+ s.shutdown()
+ close(idleConnsClosed)
+ }()
+
+ if err := s.grpcServer.Serve(listen); err != nil {
+ return e.Wrap(op, err)
+ }
+
+ <-idleConnsClosed
+
+ return nil
+}
+
+func (s *server) shutdown() {
+ s.logger.Info("shutting down")
+
+ s.grpcServer.GracefulStop()
+}
+
+// loadConfig creates a new TLS config from the given certificate and key files.
+func loadConfig(certFilename, keyFilename string) (*tls.Config, error) {
+ serverCert, err := tls.LoadX509KeyPair(certFilename, keyFilename)
+ if err != nil {
+ return nil, err
+ }
+
+ return &tls.Config{
+ MinVersion: tls.VersionTLS13,
+ Certificates: []tls.Certificate{serverCert},
+ ClientAuth: tls.NoClientCert,
+ }, nil
+}
diff --git a/server/internal/delivery/grpc/user.go b/server/internal/delivery/grpc/user.go
new file mode 100644
index 0000000..25bcd7a
--- /dev/null
+++ b/server/internal/delivery/grpc/user.go
@@ -0,0 +1,64 @@
+package grpc
+
+import (
+ "context"
+ "errors"
+
+ pb "github.com/Karzoug/goph_keeper/common/grpc"
+ "github.com/Karzoug/goph_keeper/pkg/logger/slog/sl"
+ "github.com/Karzoug/goph_keeper/server/internal/service"
+)
+
+func (s *server) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
+ const op = "register user"
+
+ if err := s.service.Register(ctx, req.Email, req.Hash); err != nil {
+ switch {
+ case errors.Is(err, service.ErrInvalidEmailFormat):
+ return nil, pb.ErrInvalidEmailFormat
+ case errors.Is(err, service.ErrInvalidHashFormat):
+ return nil, pb.ErrInvalidHashFormat
+ case errors.Is(err, service.ErrUserAlreadyExists):
+ return nil, pb.ErrUserAlreadyExists
+ default:
+ s.logger.Error(op, sl.Error(err))
+ return nil, pb.ErrInternal
+ }
+ }
+
+ return &pb.RegisterResponse{}, nil
+}
+
+func (s *server) Login(ctx context.Context, req *pb.LoginRequest) (*pb.LoginResponse, error) {
+ const op = "login user"
+
+ var (
+ token string
+ err error
+ )
+ if req.EmailCode != "" {
+ token, err = s.service.LoginWithEmailCode(ctx, req.Email, req.Hash, req.EmailCode)
+ } else {
+ token, err = s.service.Login(ctx, req.Email, req.Hash)
+ }
+
+ if err != nil {
+ switch {
+ case errors.Is(err, service.ErrInvalidEmailFormat):
+ return nil, pb.ErrInvalidEmailFormat
+ case errors.Is(err, service.ErrInvalidHashFormat):
+ return nil, pb.ErrInvalidHashFormat
+ case errors.Is(err, service.ErrUserInvalidHash):
+ return nil, pb.ErrUserInvalidHash
+ case errors.Is(err, service.ErrUserEmailNotVerified):
+ return nil, pb.ErrUserEmailNotVerified
+ case errors.Is(err, service.ErrUserNotExists):
+ return nil, pb.ErrUserNotExists
+ default:
+ s.logger.Error(op, sl.Error(err))
+ return nil, pb.ErrInternal
+ }
+ }
+
+ return &pb.LoginResponse{Token: token}, nil
+}
diff --git a/server/internal/delivery/grpc/vault.go b/server/internal/delivery/grpc/vault.go
new file mode 100644
index 0000000..9d431a7
--- /dev/null
+++ b/server/internal/delivery/grpc/vault.go
@@ -0,0 +1,69 @@
+package grpc
+
+import (
+ "context"
+ "errors"
+
+ pb "github.com/Karzoug/goph_keeper/common/grpc"
+ "github.com/Karzoug/goph_keeper/common/model/vault"
+ "github.com/Karzoug/goph_keeper/pkg/logger/slog/sl"
+ "github.com/Karzoug/goph_keeper/server/internal/delivery/grpc/interceptor/auth"
+ "github.com/Karzoug/goph_keeper/server/internal/service"
+)
+
+func (s *server) ListVaultItems(ctx context.Context, req *pb.ListVaultItemsRequest) (*pb.ListVaultItemsResponse, error) {
+ const op = "list vault items"
+
+ email, err := auth.EmailFromContext(ctx)
+ if err != nil {
+ return nil, pb.ErrEmptyAuthData
+ }
+ items, err := s.service.ListVaultItems(ctx, email, req.Since)
+ if err != nil {
+ s.logger.Error(op, sl.Error(err))
+ return nil, pb.ErrInternal
+ }
+ pbItems := make([]*pb.VaultItem, len(items))
+ for i := 0; i < len(items); i++ {
+ pbItems[i] = &pb.VaultItem{
+ Id: items[i].ID,
+ Name: items[i].Name,
+ Itype: pb.IType(items[i].Type),
+ Value: items[i].Value,
+ ServerUpdatedAt: items[i].ServerUpdatedAt,
+ }
+ }
+ return &pb.ListVaultItemsResponse{
+ Items: pbItems,
+ }, nil
+}
+
+func (s *server) SetVaultItem(ctx context.Context, req *pb.SetVaultItemRequest) (*pb.SetVaultItemResponse, error) {
+ const op = "set vault item"
+
+ email, err := auth.EmailFromContext(ctx)
+ if err != nil {
+ return nil, pb.ErrEmptyAuthData
+ }
+ t, err := s.service.SetVaultItem(ctx, email, vault.Item{
+ ID: req.Item.Id,
+ Name: req.Item.Name,
+ Type: vault.ItemType(req.Item.Itype),
+ Value: req.Item.Value,
+ ServerUpdatedAt: req.Item.ServerUpdatedAt,
+ })
+ if err != nil {
+ switch {
+ case errors.Is(err, service.ErrVaultItemVersionConflict):
+ return nil, pb.ErrVaultItemConflictVersion
+ case errors.Is(err, service.ErrVaultItemValueTooBig):
+ return nil, pb.ErrVaultItemValueTooBig
+ default:
+ s.logger.Error(op, sl.Error(err))
+ return nil, pb.ErrInternal
+ }
+ }
+ return &pb.SetVaultItemResponse{
+ ServerUpdatedAt: t,
+ }, nil
+}
diff --git a/server/internal/delivery/rtask/rtask.go b/server/internal/delivery/rtask/rtask.go
new file mode 100644
index 0000000..99e2410
--- /dev/null
+++ b/server/internal/delivery/rtask/rtask.go
@@ -0,0 +1,76 @@
+package rtask
+
+import (
+ "context"
+
+ "log/slog"
+
+ "github.com/hibiken/asynq"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ "github.com/Karzoug/goph_keeper/server/internal/config/rtask"
+ "github.com/Karzoug/goph_keeper/server/internal/service"
+ "github.com/Karzoug/goph_keeper/server/internal/service/task"
+)
+
+type server struct {
+ logger *slog.Logger
+ service *service.Service
+
+ asynqServer *asynq.Server
+}
+
+func New(cfg rtask.Config, service *service.Service, logger *slog.Logger) (*server, error) {
+ const op = "create rtask server"
+
+ opt, err := asynq.ParseRedisURI(cfg.Storage.URI)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ asynqServer := asynq.NewServer(opt,
+ asynq.Config{
+ Concurrency: cfg.Concurrency,
+ LogLevel: asynq.InfoLevel,
+ },
+ )
+
+ srv := &server{
+ logger: logger.With("from", "rtask server"),
+ service: service,
+ asynqServer: asynqServer,
+ }
+
+ return srv, nil
+}
+
+func (s *server) Run(ctx context.Context) error {
+ const op = "rtask server: run"
+
+ s.logger.Info("running")
+
+ mux := asynq.NewServeMux()
+ mux.HandleFunc(task.TypeWelcomeVerificationEmail, s.service.HandleWelcomeVerificationEmailTask)
+
+ idleConnsClosed := make(chan struct{})
+
+ go func() {
+ <-ctx.Done()
+ s.shutdown()
+ close(idleConnsClosed)
+ }()
+
+ if err := s.asynqServer.Start(mux); err != nil {
+ return e.Wrap(op, err)
+ }
+
+ <-idleConnsClosed
+
+ return nil
+}
+
+func (s *server) shutdown() {
+ s.logger.Info("shutting down")
+
+ s.asynqServer.Shutdown()
+}
diff --git a/server/internal/model/auth/key.go b/server/internal/model/auth/key.go
new file mode 100644
index 0000000..57ffa21
--- /dev/null
+++ b/server/internal/model/auth/key.go
@@ -0,0 +1,42 @@
+package auth
+
+import (
+ "errors"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+
+ "github.com/matthewhartstonge/argon2"
+)
+
+var ErrEmptyHash = errors.New("empty hash")
+
+// Key is a user auth data (hash) to store on server,
+// it's an Argon2 hash based on client auth hash and random salt.
+type Key []byte
+
+// NewKey returns a new auth key to store on server.
+func NewKey(hash []byte) (Key, error) {
+ const op = "model: create auth key"
+
+ if len(hash) == 0 {
+ return nil, ErrEmptyHash
+ }
+ argon := argon2.DefaultConfig()
+
+ encoded, err := argon.HashEncoded(hash)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ return Key(encoded), nil
+}
+
+// Verify returns true if hash matches the key and otherwise false.
+func (k Key) Verify(hash []byte) bool {
+ ok, err := argon2.VerifyEncoded(hash, k)
+ if err != nil || !ok {
+ return false
+ }
+
+ return true
+}
diff --git a/server/internal/model/auth/key_test.go b/server/internal/model/auth/key_test.go
new file mode 100644
index 0000000..f8a1146
--- /dev/null
+++ b/server/internal/model/auth/key_test.go
@@ -0,0 +1,60 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestKey_New_Verify(t *testing.T) {
+ tests := []struct {
+ name string
+ hash []byte
+ wantErr bool
+ }{
+ {
+ name: "valid",
+ hash: []byte("test"),
+ wantErr: false,
+ },
+ {
+ name: "valid",
+ hash: []byte("*07S7A7$V*ufxm!4NKgTlrhSI4gk3BTReVAegz4XL52j$v11l09HfUhW7UB#fXG&JHFIyUaEVm$kxyr2iCUuo7z#kd*&vvRKN92"),
+ wantErr: false,
+ },
+ {
+ name: "invalid: empty hash",
+ hash: []byte(""),
+ wantErr: true,
+ },
+ {
+ name: "invalid: nil hash",
+ hash: []byte(""),
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ k, err := NewKey(tt.hash)
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NotNil(t, k)
+ assert.True(t, k.Verify(tt.hash))
+ })
+ }
+}
+
+func TestKey_Verify(t *testing.T) {
+ h := []byte("*07S7A7$V*ufxm!4NKgTlrhSI4gk3BTReVAegz4XL52j$v11l09HfUhW7UB#fXG&JHFIyUaEVm$kxyr2iCUuo7z#kd*&vvRKN92")
+ k, err := NewKey(h)
+ require.NoError(t, err)
+ assert.NotNil(t, k)
+
+ assert.True(t, k.Verify(h))
+ assert.False(t, k.Verify([]byte("test")))
+ assert.False(t, k.Verify([]byte("")))
+ assert.False(t, k.Verify(nil))
+}
diff --git a/server/internal/model/auth/token/token.go b/server/internal/model/auth/token/token.go
new file mode 100644
index 0000000..064848d
--- /dev/null
+++ b/server/internal/model/auth/token/token.go
@@ -0,0 +1,121 @@
+package token
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/rs/xid"
+)
+
+const (
+ tokenVersion byte = 1
+ tokenSize = 1 + 12 + 15 + 32
+ MinSecretKeyLength = 16
+)
+
+var (
+ ErrInvalidTokenFormat = errors.New("invalid token format")
+ ErrSecretKeyTooShort = fmt.Errorf("secret key must be more or equal than %d bytes", MinSecretKeyLength)
+)
+
+// SecretKey is a key for signing tokens.
+// It should be longer than MinSecretKeyLength bytes.
+type SecretKey []byte
+
+func (t *SecretKey) UnmarshalText(text []byte) error {
+ if len(text) < 16 {
+ return ErrSecretKeyTooShort
+ }
+ *t = text
+ return nil
+}
+
+// token is a auth token
+type token struct {
+ id string
+ exp time.Time
+ sub string
+}
+
+// New returns a new token with unique ID.
+func New(exp time.Time, key SecretKey) *token {
+ id := xid.New()
+
+ t := &token{
+ id: id.String(),
+ exp: exp,
+ }
+
+ b := make([]byte, tokenSize)
+
+ b[0] = tokenVersion
+ copy(b[1:13], id.Bytes())
+ expBin, _ := exp.MarshalBinary()
+ copy(b[13:], expBin)
+ copy(b[28:], generateSign(b[:28], key))
+
+ t.sub = hex.EncodeToString(b)
+
+ return t
+}
+
+// FromString returns a token from a string.
+func FromString(s string, key SecretKey) (*token, error) {
+ b, err := hex.DecodeString(s)
+ if err != nil {
+ return nil, ErrInvalidTokenFormat
+ }
+
+ if len(b) != tokenSize {
+ return nil, ErrInvalidTokenFormat
+ }
+
+ if b[0] != tokenVersion {
+ return nil, ErrInvalidTokenFormat
+ }
+
+ id, err := xid.FromBytes(b[1:13])
+ if err != nil {
+ return nil, ErrInvalidTokenFormat
+ }
+ t := &token{
+ id: id.String(),
+ sub: s,
+ }
+ err = t.exp.UnmarshalBinary(b[13:28])
+ if err != nil {
+ return nil, ErrInvalidTokenFormat
+ }
+
+ if !bytes.Equal(b[28:], generateSign(b[:28], key)) {
+ return nil, ErrInvalidTokenFormat
+ }
+
+ return t, nil
+}
+
+// IsExpired returns true if token is expired.
+func (t *token) IsExpired() bool {
+ return t.exp.Before(time.Now())
+}
+
+// ID returns an ID of token to store on server.
+func (t *token) ID() string {
+ return t.sub
+}
+
+// String returns a string representation of token to send to client.
+func (t *token) String() string {
+ return t.sub
+}
+
+func generateSign(data []byte, key SecretKey) []byte {
+ h := hmac.New(sha256.New, key)
+ h.Write(data)
+ return h.Sum(nil)
+}
diff --git a/server/internal/model/auth/token/token_test.go b/server/internal/model/auth/token/token_test.go
new file mode 100644
index 0000000..4eacc55
--- /dev/null
+++ b/server/internal/model/auth/token/token_test.go
@@ -0,0 +1,32 @@
+package token
+
+import (
+ "math/rand"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFromString(t *testing.T) {
+ b := make([]byte, 20)
+ _, err := rand.Read(b)
+ require.NoError(t, err)
+
+ sk := SecretKey(b)
+
+ tkn := New(time.Now().Add(time.Hour), sk)
+ s := tkn.String()
+
+ tkn2, err := FromString(s, sk)
+ require.NoError(t, err)
+
+ assert.Equal(t, tkn.id, tkn.id)
+ assert.WithinDuration(t, tkn.exp, tkn2.exp, 0)
+}
+
+func TestTimeMarshalBinary(t *testing.T) {
+ b, _ := time.Now().MarshalBinary()
+ require.Len(t, b, 15, "token format version 1 expected 15 bytes for time")
+}
diff --git a/server/internal/model/user/user.go b/server/internal/model/user/user.go
new file mode 100644
index 0000000..c608797
--- /dev/null
+++ b/server/internal/model/user/user.go
@@ -0,0 +1,97 @@
+package user
+
+import (
+ "errors"
+ "net"
+ "net/mail"
+ "strings"
+ "time"
+
+ "golang.org/x/net/idna"
+
+ "github.com/Karzoug/goph_keeper/server/internal/model/auth"
+)
+
+var (
+ ErrInvalidHashFormat = errors.New("invalid hash format")
+ ErrInvalidEmailFormat = errors.New("invalid email format")
+ errUnresolvableHost = errors.New("unresolvable host")
+)
+
+// User is a service user (client).
+type User struct {
+ Email string
+ IsEmailVerified bool
+ AuthKey auth.Key
+ CreatedAt time.Time
+}
+
+// New returns a new user.
+func New(email string, authHash []byte) (User, error) {
+ if !isValidEmail(email) {
+ return User{}, ErrInvalidEmailFormat
+ }
+
+ authKey, err := auth.NewKey(authHash)
+ if err != nil {
+ return User{}, ErrInvalidHashFormat
+ }
+
+ return User{
+ Email: email,
+ IsEmailVerified: false,
+ AuthKey: authKey,
+ CreatedAt: time.Now(),
+ }, nil
+}
+
+func isValidEmail(email string) bool {
+ if len(email) == 0 {
+ return false
+ }
+ e, err := mail.ParseAddress(email)
+ if err != nil {
+ return false
+ }
+ email = e.Address
+ if err := validateMX(email); err != nil {
+ return false
+ }
+
+ return true
+}
+
+// validateMX validate if MX record exists for a domain.
+func validateMX(email string) error {
+ _, host := split(email)
+ if len(host) == 0 {
+ return errUnresolvableHost
+ }
+ host = hostToASCII(host)
+ if _, err := net.LookupMX(host); err != nil {
+ return errUnresolvableHost
+ }
+
+ return nil
+}
+
+func split(email string) (account, host string) {
+ i := strings.LastIndexByte(email, '@')
+ // If no @ present, not a valid email.
+ if i < 0 {
+ return
+ }
+ account = email[:i]
+ host = email[i+1:]
+ return
+}
+
+// domainToASCII converts any internationalized domain names to ASCII
+// reference: https://en.wikipedia.org/wiki/Punycode
+func hostToASCII(host string) string {
+ asciiDomain, err := idna.ToASCII(host)
+ if err != nil {
+ return host
+ }
+ return asciiDomain
+}
diff --git a/server/internal/model/user/user_test.go b/server/internal/model/user/user_test.go
new file mode 100644
index 0000000..0cd6370
--- /dev/null
+++ b/server/internal/model/user/user_test.go
@@ -0,0 +1,75 @@
+package user
+
+import (
+ "testing"
+)
+
+func TestNew(t *testing.T) {
+ type args struct {
+ email string
+ authHash []byte
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "positive",
+ args: args{
+ email: "test@example.com",
+ authHash: []byte("12345"),
+ },
+ wantErr: false,
+ },
+ {
+ name: "negative: empty hash",
+ args: args{
+ email: "test@example.com",
+ authHash: []byte(""),
+ },
+ wantErr: true,
+ },
+ {
+ name: "negative: empty email",
+ args: args{
+ email: "",
+ authHash: []byte("123456"),
+ },
+ wantErr: true,
+ },
+ {
+ name: "negative: wrong email format",
+ args: args{
+ email: "testexamplecom",
+ authHash: []byte("123456"),
+ },
+ wantErr: true,
+ },
+ {
+ name: "negative: not valid domain",
+ args: args{
+ email: "info@pupkinsupercompany.com",
+ authHash: []byte("123456"),
+ },
+ wantErr: true,
+ },
+ {
+ name: "negative: wrong email format #2",
+ args: args{
+ email: "infopupkin@",
+ authHash: []byte("123456"),
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := New(tt.args.email, tt.args.authHash)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ })
+ }
+}
diff --git a/server/internal/repository/mail/mail.go b/server/internal/repository/mail/mail.go
new file mode 100644
index 0000000..0ded613
--- /dev/null
+++ b/server/internal/repository/mail/mail.go
@@ -0,0 +1,14 @@
+package mail
+
+type Contact struct {
+ Email string
+ Name string
+}
+
+type Mail struct {
+ To Contact
+ From Contact
+ Subject string
+ HTML string
+ Text string
+}
diff --git a/server/internal/repository/mail/smtp/smtp.go b/server/internal/repository/mail/smtp/smtp.go
new file mode 100644
index 0000000..bcbc5c2
--- /dev/null
+++ b/server/internal/repository/mail/smtp/smtp.go
@@ -0,0 +1,92 @@
+package smtp
+
+import (
+ "context"
+ "crypto/tls"
+ "time"
+
+ mail "github.com/xhit/go-simple-mail/v2"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ "github.com/Karzoug/goph_keeper/server/internal/config/smtp"
+ rmail "github.com/Karzoug/goph_keeper/server/internal/repository/mail"
+)
+
+type client struct {
+ cfg smtp.Config
+ server *mail.SMTPServer
+}
+
+func New(cfg smtp.Config) (*client, error) {
+ const op = "create smtp client"
+
+ server := mail.NewSMTPClient()
+ server.Host = cfg.Host
+ server.Port = cfg.Port
+ server.Username = cfg.Username
+ server.Password = cfg.Password
+ server.Encryption = mail.EncryptionSTARTTLS
+
+ server.KeepAlive = false
+ server.ConnectTimeout = 10 * time.Second
+ server.SendTimeout = 10 * time.Second
+
+ server.TLSConfig = &tls.Config{
+ ServerName: server.Host,
+ MinVersion: tls.VersionTLS13,
+ }
+
+ smtpClient, err := server.Connect()
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ defer smtpClient.Close()
+
+ c := &client{
+ cfg: cfg,
+ server: server,
+ }
+
+ return c, nil
+}
+
+func (c *client) Send(ctx context.Context, m *rmail.Mail) error {
+ const op = "smpt client: send mail"
+
+ // TODO: use pool of conn if necessary
+
+ smtpClient, err := c.server.Connect()
+
+ timeout, ok := ctx.Deadline()
+ if ok {
+ smtpClient.SendTimeout = time.Until(timeout)
+ }
+
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ email := mail.NewMSG()
+ email.SetFrom(m.From.Email).
+ AddTo(m.To.Email).
+ SetSubject(m.Subject)
+
+ email.SetBody(mail.TextHTML, m.HTML)
+ email.AddAlternative(mail.TextPlain, m.Text)
+
+ if email.Error != nil {
+ return e.Wrap(op, err)
+ }
+
+ err = email.Send(smtpClient)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ return nil
+}
+
+func (c *client) Validate(email string) error {
+ // TODO: validate email
+ return nil
+}
diff --git a/server/internal/repository/rtask/client.go b/server/internal/repository/rtask/client.go
new file mode 100644
index 0000000..735a0a5
--- /dev/null
+++ b/server/internal/repository/rtask/client.go
@@ -0,0 +1,43 @@
+package rtask
+
+import (
+ "time"
+
+ "log/slog"
+
+ "github.com/hibiken/asynq"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+)
+
+type Client struct {
+ asyncClient *asynq.Client
+ logger *slog.Logger
+}
+
+func New(redisDsnURI string, logger *slog.Logger) (Client, error) {
+ const op = "create rtask client"
+
+ opt, err := asynq.ParseRedisURI(redisDsnURI)
+ if err != nil {
+ return Client{}, e.Wrap(op, err)
+ }
+
+ return Client{
+ asyncClient: asynq.NewClient(opt),
+ logger: logger.With("from", "rtask client"),
+ }, nil
+}
+
+func (c *Client) Enqueue(task *asynq.Task, timeout time.Duration) error {
+ const op = "task client: enqueue"
+
+ info, err := c.asyncClient.Enqueue(task, asynq.Timeout(timeout))
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ c.logger.Debug("successfully enqueued task",
+ slog.String("task id", info.ID),
+ slog.String("task type", info.Type))
+ return nil
+}
diff --git a/server/internal/repository/storage/error.go b/server/internal/repository/storage/error.go
new file mode 100644
index 0000000..3c79cdf
--- /dev/null
+++ b/server/internal/repository/storage/error.go
@@ -0,0 +1,9 @@
+package storage
+
+import "errors"
+
+var (
+ ErrRecordAlreadyExists = errors.New("record already exists")
+ ErrRecordNotFound = errors.New("record not found")
+ ErrNoRecordsAffected = errors.New("no records affected")
+)
diff --git a/server/internal/repository/storage/postgres/db.go b/server/internal/repository/storage/postgres/db.go
new file mode 100644
index 0000000..a0abbb6
--- /dev/null
+++ b/server/internal/repository/storage/postgres/db.go
@@ -0,0 +1,47 @@
+package postgres
+
+import (
+ "context"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ sconfig "github.com/Karzoug/goph_keeper/server/internal/config/storage"
+)
+
+const (
+ URIPreffix = "postgres:"
+ prepareDBTimeout = 5 * time.Second
+ duplicateKeyErrorCode = "23505"
+)
+
+type storage struct {
+ db *pgxpool.Pool
+}
+
+func New(ctx context.Context, cfg sconfig.Config) (*storage, error) {
+ op := "create postgres storage"
+
+ ctx, cancel := context.WithTimeout(ctx, prepareDBTimeout)
+ defer cancel()
+
+ pool, err := pgxpool.New(ctx, cfg.URI)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ if err = pool.Ping(ctx); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ return &storage{
+ db: pool,
+ }, nil
+}
+
+func (s *storage) Close() error {
+ s.db.Close()
+
+ return nil
+}
diff --git a/server/internal/repository/storage/postgres/user.go b/server/internal/repository/storage/postgres/user.go
new file mode 100644
index 0000000..34ccf2f
--- /dev/null
+++ b/server/internal/repository/storage/postgres/user.go
@@ -0,0 +1,76 @@
+package postgres
+
+import (
+ "context"
+ "errors"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ "github.com/Karzoug/goph_keeper/server/internal/model/user"
+ serr "github.com/Karzoug/goph_keeper/server/internal/repository/storage"
+)
+
+func (s *storage) AddUser(ctx context.Context, u user.User) error {
+ const op = "postgres: add user"
+
+ _, err := s.db.Exec(ctx,
+ `INSERT
+ INTO users(email, is_email_verified, auth_key, created_at)
+ VALUES ($1, $2, $3, $4)`,
+ u.Email, u.IsEmailVerified, []byte(u.AuthKey), u.CreatedAt)
+ if err != nil {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == duplicateKeyErrorCode {
+ return e.Wrap(op, serr.ErrRecordAlreadyExists)
+ }
+ return e.Wrap(op, err)
+ }
+
+ return nil
+}
+
+func (s *storage) GetUser(ctx context.Context, email string) (user.User, error) {
+ const op = "postgres: get user"
+
+ u := user.User{
+ Email: email,
+ }
+ var byteKey []byte
+ err := s.db.QueryRow(ctx,
+ `SELECT is_email_verified, auth_key, created_at
+ FROM users
+ WHERE email = $1`, email).
+ Scan(&u.IsEmailVerified, &byteKey, &u.CreatedAt)
+
+ u.AuthKey = byteKey
+
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return user.User{}, e.Wrap(op, serr.ErrRecordNotFound)
+ }
+ return user.User{}, e.Wrap(op, err)
+ }
+
+ return u, nil
+}
+
+func (s *storage) UpdateUser(ctx context.Context, u user.User) error {
+ const op = "postgres: update user"
+
+ tag, err := s.db.Exec(ctx,
+ `UPDATE users
+ SET is_email_verified = $1, auth_key = $2, created_at = $3
+ WHERE email = $4`,
+ u.IsEmailVerified, []byte(u.AuthKey), u.CreatedAt, u.Email)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ if tag.RowsAffected() == 0 { // driver specific
+ return e.Wrap(op, serr.ErrNoRecordsAffected)
+ }
+
+ return nil
+}
diff --git a/server/internal/repository/storage/postgres/vault.go b/server/internal/repository/storage/postgres/vault.go
new file mode 100644
index 0000000..d5b2314
--- /dev/null
+++ b/server/internal/repository/storage/postgres/vault.go
@@ -0,0 +1,68 @@
+package postgres
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5"
+
+ "github.com/Karzoug/goph_keeper/common/model/vault"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ serr "github.com/Karzoug/goph_keeper/server/internal/repository/storage"
+)
+
+func (s *storage) SetVaultItem(ctx context.Context, email string, item vault.Item) error {
+ const op = "postgres: set vault item"
+
+ res, err := s.db.Exec(ctx,
+ `INSERT INTO vaults(id,email,name,type,value,updated_at,is_deleted) VALUES($1, $2, $3, $4, $5, $6, $7)
+ ON CONFLICT(id,email)
+ DO UPDATE SET name = excluded.name, type = excluded.type, value=excluded.value, updated_at=excluded.updated_at, is_deleted=excluded.is_deleted
+ WHERE vaults.updated_at=$8;`,
+ item.ID, email, item.Name, item.Type, item.Value, item.ClientUpdatedAt, item.IsDeleted, item.ServerUpdatedAt)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ if res.RowsAffected() == 0 {
+ return serr.ErrNoRecordsAffected
+ }
+
+ return nil
+}
+
+func (s *storage) ListVaultItems(ctx context.Context, email string, since int64) ([]vault.Item, error) {
+ const op = "postgres: list vault items"
+
+ var (
+ rows pgx.Rows
+ err error
+ )
+ if since != 0 {
+ rows, err = s.db.Query(ctx,
+ `SELECT id, name, type, value, updated_at, is_deleted FROM vaults WHERE email = $1 AND updated_at > $2;`, email, since)
+ } else {
+ rows, err = s.db.Query(ctx,
+ `SELECT id, name, type, value, updated_at, is_deleted FROM vaults WHERE email = $1`, email)
+ }
+
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ defer rows.Close()
+
+ res, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (vault.Item, error) {
+ var item vault.Item
+ err = rows.Scan(&item.ID, &item.Name, &item.Type, &item.Value, &item.ServerUpdatedAt, &item.IsDeleted)
+ return item, err
+ })
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ err = rows.Err()
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ return res, nil
+}
diff --git a/server/internal/repository/storage/redis/redis.go b/server/internal/repository/storage/redis/redis.go
new file mode 100644
index 0000000..c537544
--- /dev/null
+++ b/server/internal/repository/storage/redis/redis.go
@@ -0,0 +1,71 @@
+package redis
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ sconfig "github.com/Karzoug/goph_keeper/server/internal/config/storage"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/storage"
+)
+
+const (
+ URIPreffix = "redis:"
+)
+
+type client struct {
+ rdb *redis.Client
+}
+
+// New creates a new redis client.
+func New(cfg sconfig.Config) (*client, error) {
+ const op = "create redis storage"
+
+ opt, err := redis.ParseURL(cfg.URI)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ return &client{
+ rdb: redis.NewClient(opt),
+ }, nil
+}
+
+// Get returns value by key.
+func (q *client) Get(ctx context.Context, key string) (string, error) {
+ const op = "redis: get"
+
+ val, err := q.rdb.Get(ctx, key).Result()
+ if errors.Is(err, redis.Nil) {
+ return "", e.Wrap(op, storage.ErrRecordNotFound)
+ }
+ return val, e.Wrap(op, err)
+}
+
+// Set sets value by key.
+func (q *client) Set(ctx context.Context, key, value string, expiration time.Duration) error {
+ const op = "redis: set"
+
+ return e.Wrap(op, q.rdb.Set(ctx, key, value, expiration).Err())
+}
+
+// Delete deletes value by key.
+func (q *client) Delete(ctx context.Context, key string) error {
+ const op = "redis: delete"
+
+ val, err := q.rdb.Del(ctx, key).Result()
+ if val == 0 {
+ return e.Wrap(op, storage.ErrNoRecordsAffected)
+ }
+ return e.Wrap(op, err)
+}
+
+// Close closes the redis client, releasing any open resources.
+func (q *client) Close() error {
+ const op = "redis: close"
+
+ return e.Wrap(op, q.rdb.Close())
+}
diff --git a/server/internal/repository/storage/smap/smap.go b/server/internal/repository/storage/smap/smap.go
new file mode 100644
index 0000000..5ed2677
--- /dev/null
+++ b/server/internal/repository/storage/smap/smap.go
@@ -0,0 +1,122 @@
+package smap
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/storage"
+)
+
+type smap struct {
+ items sync.Map
+ close chan struct{}
+}
+
+type item struct {
+ data string
+ expires int64
+}
+
+// New creates sync map storage type of key-value.
+// Simple, but thread-safe. Designed mainly for testing purposes.
+func New(cleaningInterval time.Duration) *smap {
+ smap := &smap{
+ close: make(chan struct{}),
+ }
+
+ go func() {
+ ticker := time.NewTicker(cleaningInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ now := time.Now().UnixNano()
+
+ smap.items.Range(func(key, value any) bool {
+ item := value.(item)
+
+ if item.expires > 0 && now > item.expires {
+ smap.items.Delete(key)
+ }
+
+ return true
+ })
+
+ case <-smap.close:
+ return
+ }
+ }
+ }()
+
+ return smap
+}
+
+// Get returns value by key.
+func (smap *smap) Get(_ context.Context, key string) (string, error) {
+ const op = "sync map: get"
+
+ obj, exists := smap.items.Load(key)
+
+ if !exists {
+ return "", e.Wrap(op, storage.ErrRecordNotFound)
+ }
+
+ item := obj.(item)
+
+ if item.expires > 0 && time.Now().UnixNano() > item.expires {
+ smap.items.Delete(key)
+ return "", e.Wrap(op, storage.ErrRecordNotFound)
+ }
+
+ return item.data, nil
+}
+
+// Set sets value by key.
+func (smap *smap) Set(_ context.Context, key string, value string, duration time.Duration) error {
+ var expires int64
+
+ if duration > 0 {
+ expires = time.Now().Add(duration).UnixNano()
+ }
+
+ smap.items.Store(key, item{
+ data: value,
+ expires: expires,
+ })
+
+ return nil
+}
+
+// Range calls f sequentially for each key and value present in the storage.
+func (smap *smap) Range(f func(key, value any) bool) {
+ now := time.Now().UnixNano()
+
+ fn := func(key, value any) bool {
+ item := value.(item)
+
+ if item.expires > 0 && now > item.expires {
+ return true
+ }
+
+ return f(key, item.data)
+ }
+
+ smap.items.Range(fn)
+}
+
+// Delete deletes value by key.
+func (smap *smap) Delete(_ context.Context, key string) error {
+ smap.items.Delete(key)
+ return nil
+}
+
+// Close closes storage: cleans internal map and stop cleaning work.
+func (smap *smap) Close() error {
+ smap.close <- struct{}{}
+ smap.items = sync.Map{}
+
+ return nil
+}
diff --git a/server/internal/repository/storage/sqlite/db.go b/server/internal/repository/storage/sqlite/db.go
new file mode 100644
index 0000000..e171ef4
--- /dev/null
+++ b/server/internal/repository/storage/sqlite/db.go
@@ -0,0 +1,45 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+
+ _ "modernc.org/sqlite"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ sconfig "github.com/Karzoug/goph_keeper/server/internal/config/storage"
+)
+
+const (
+ // SQLite uses the "file:" URI syntax to identify database files,
+ // see: https://www.sqlite.org/uri.html
+ URIPreffix = "file:"
+ duplicateKeyErrorCode = "1555"
+)
+
+type storage struct {
+ db *sql.DB
+}
+
+func New(ctx context.Context, cfg sconfig.Config) (*storage, error) {
+ op := "create sqlite storage"
+
+ db, err := sql.Open("sqlite", cfg.URI)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ if err := db.PingContext(ctx); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ return &storage{
+ db: db,
+ }, nil
+}
+
+func (s *storage) Close() error {
+ const op = "sqlite: close"
+
+ return e.Wrap(op, s.db.Close())
+}
diff --git a/server/internal/repository/storage/sqlite/user.go b/server/internal/repository/storage/sqlite/user.go
new file mode 100644
index 0000000..78342c2
--- /dev/null
+++ b/server/internal/repository/storage/sqlite/user.go
@@ -0,0 +1,74 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "strings"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ "github.com/Karzoug/goph_keeper/server/internal/model/user"
+ serr "github.com/Karzoug/goph_keeper/server/internal/repository/storage"
+)
+
+func (s *storage) AddUser(ctx context.Context, u user.User) error {
+ const op = "sqlite: add user"
+
+ _, err := s.db.ExecContext(ctx,
+ `INSERT
+ INTO users(email, is_email_verified, auth_key, created_at)
+ VALUES (?, ?, ?, ?)`,
+ u.Email, u.IsEmailVerified, u.AuthKey, u.CreatedAt)
+ if err != nil {
+ if strings.Contains(err.Error(), duplicateKeyErrorCode) {
+ return e.Wrap(op, serr.ErrRecordAlreadyExists)
+ }
+ return e.Wrap(op, err)
+ }
+
+ return nil
+}
+
+func (s *storage) GetUser(ctx context.Context, email string) (user.User, error) {
+ const op = "sqlite: get user"
+
+ u := user.User{
+ Email: email,
+ }
+ err := s.db.QueryRowContext(ctx,
+ `SELECT is_email_verified, auth_key, created_at
+ FROM users
+ WHERE email = ?`, email).
+ Scan(&u.IsEmailVerified, &u.AuthKey, &u.CreatedAt)
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return user.User{}, e.Wrap(op, serr.ErrRecordNotFound)
+ }
+ return user.User{}, e.Wrap(op, err)
+ }
+
+ return u, nil
+}
+
+func (s *storage) UpdateUser(ctx context.Context, u user.User) error {
+ const op = "sqlite: update user"
+
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE users
+ SET is_email_verified = ?, auth_key = ?, created_at = ?
+ WHERE email = ?`,
+ u.IsEmailVerified, u.AuthKey, u.CreatedAt, u.Email)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ count, err := res.RowsAffected() // driver specific
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ if count == 0 {
+ return e.Wrap(op, serr.ErrNoRecordsAffected)
+ }
+
+ return nil
+}
diff --git a/server/internal/repository/storage/sqlite/vault.go b/server/internal/repository/storage/sqlite/vault.go
new file mode 100644
index 0000000..3d8d4aa
--- /dev/null
+++ b/server/internal/repository/storage/sqlite/vault.go
@@ -0,0 +1,71 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/Karzoug/goph_keeper/common/model/vault"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ serr "github.com/Karzoug/goph_keeper/server/internal/repository/storage"
+)
+
+func (s *storage) SetVaultItem(ctx context.Context, email string, item vault.Item) error {
+ const op = "sqlite: set vault item"
+
+ res, err := s.db.ExecContext(ctx,
+ `INSERT INTO vaults(id,email,name,type,value,updated_at,is_deleted) VALUES(?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id,email)
+ DO UPDATE SET name = excluded.name, type = excluded.type, value=excluded.value, updated_at=excluded.updated_at, is_deleted=excluded.is_deleted
+ WHERE vaults.updated_at=?;`,
+ item.ID, email, item.Name, item.Type, item.Value, item.ClientUpdatedAt, item.IsDeleted, item.ServerUpdatedAt)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ count, err := res.RowsAffected()
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ if count == 0 {
+ return serr.ErrNoRecordsAffected
+ }
+
+ return nil
+}
+
+func (s *storage) ListVaultItems(ctx context.Context, email string, since int64) ([]vault.Item, error) {
+ const op = "sqlite: list vault items"
+
+ var (
+ rows *sql.Rows
+ err error
+ )
+ if since != 0 {
+ rows, err = s.db.QueryContext(ctx,
+ `SELECT id, name, type, value, updated_at, is_deleted FROM vaults WHERE email = ? AND updated_at > ?;`, email, since)
+ } else {
+ rows, err = s.db.QueryContext(ctx,
+ `SELECT id, name, type, value, updated_at, is_deleted FROM vaults WHERE email = ?`, email)
+ }
+
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ defer rows.Close()
+
+ res := make([]vault.Item, 0)
+ for rows.Next() {
+ var item vault.Item
+ err := rows.Scan(&item.ID, &item.Name, &item.Type, &item.Value, &item.ServerUpdatedAt, &item.IsDeleted)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ res = append(res, item)
+ }
+ err = rows.Err()
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ return res, nil
+}
diff --git a/server/internal/service/error.go b/server/internal/service/error.go
new file mode 100644
index 0000000..27eeeec
--- /dev/null
+++ b/server/internal/service/error.go
@@ -0,0 +1,16 @@
+package service
+
+import "errors"
+
+var (
+ ErrUserAlreadyExists = errors.New("user already exists")
+ ErrUserNotExists = errors.New("user not exists")
+ ErrUserEmailNotVerified = errors.New("user email not verified")
+ ErrUserInvalidHash = errors.New("user hash not valid")
+ ErrInvalidEmailFormat = errors.New("invalid email format")
+ ErrInvalidHashFormat = errors.New("invalid hash format")
+ ErrInvalidTokenFormat = errors.New("user token invalid format")
+ ErrUserNeedAuthentication = errors.New("user need authentication")
+ ErrVaultItemVersionConflict = errors.New("vault item: conflict version")
+ ErrVaultItemValueTooBig = errors.New("vault item: big value")
+)
diff --git a/server/internal/service/mail.go b/server/internal/service/mail.go
new file mode 100644
index 0000000..38fd0cb
--- /dev/null
+++ b/server/internal/service/mail.go
@@ -0,0 +1,93 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "log/slog"
+
+ "github.com/hibiken/asynq"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ am "github.com/Karzoug/goph_keeper/server/assets/mail"
+ rm "github.com/Karzoug/goph_keeper/server/internal/repository/mail"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/storage"
+ "github.com/Karzoug/goph_keeper/server/internal/service/task"
+)
+
+func (s *Service) HandleWelcomeVerificationEmailTask(ctx context.Context, t *asynq.Task) error {
+ const op = "service: handle verification email"
+
+ var p task.EmailTaskPayload
+ if err := json.Unmarshal(t.Payload(), &p); err != nil {
+ return fmt.Errorf("%s: %w, %w", op, err, task.ErrSkipRetry)
+ }
+
+ code, err := s.caches.mail.Get(ctx, p.Email)
+ if err != nil {
+ if errors.Is(err, storage.ErrRecordNotFound) {
+ return fmt.Errorf("%s: %w, %w", op, err, task.ErrSkipRetry)
+ }
+ return err
+ }
+
+ err = s.mailSender.Validate(p.Email)
+ if err != nil {
+ return fmt.Errorf("%s: %w, %w", op, err, task.ErrSkipRetry)
+ }
+
+ m, err := s.createMail(t.Type(), p.Email, code)
+ if err != nil {
+ return fmt.Errorf("%s: %w, %w", op, err, task.ErrSkipRetry)
+ }
+
+ err = s.mailSender.Send(ctx, m)
+ if err != nil {
+ // TODO: explore possible errors
+ return e.Wrap(op, err)
+ }
+
+ s.logger.Debug("welcome verification mail sended to user", slog.String("email", p.Email))
+ return nil
+}
+
+func (s *Service) createMail(typename string, email string, value any) (*rm.Mail, error) {
+ const op = "service: create mail"
+
+ var tpl am.Template
+ switch typename {
+ case task.TypeWelcomeVerificationEmail:
+ tpl = am.Templates[task.TypeWelcomeVerificationEmail]
+ default:
+ return nil, errors.New("unknown type of mail")
+ }
+
+ var buf bytes.Buffer
+ if err := tpl.HTMLTemplate.Execute(&buf, value); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ m := &rm.Mail{
+ To: rm.Contact{
+ Email: email,
+ Name: email,
+ },
+ From: rm.Contact{
+ Email: tpl.FromEmail,
+ Name: tpl.FromName,
+ },
+ Subject: tpl.Subject,
+ HTML: buf.String(),
+ }
+
+ buf.Reset()
+ if err := tpl.TextTemplate.Execute(&buf, value); err != nil {
+ return nil, e.Wrap(op, err)
+ }
+ m.Text = buf.String()
+
+ return m, nil
+}
diff --git a/server/internal/service/service.go b/server/internal/service/service.go
new file mode 100644
index 0000000..5ff7266
--- /dev/null
+++ b/server/internal/service/service.go
@@ -0,0 +1,115 @@
+package service
+
+import (
+ "context"
+ "os"
+ "time"
+
+ "log/slog"
+
+ "github.com/Karzoug/goph_keeper/common/model/vault"
+ scfg "github.com/Karzoug/goph_keeper/server/internal/config/service"
+ "github.com/Karzoug/goph_keeper/server/internal/model/user"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/mail"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/rtask"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/storage/smap"
+)
+
+type Storage interface {
+ AddUser(context.Context, user.User) error
+ GetUser(ctx context.Context, email string) (user.User, error)
+ UpdateUser(context.Context, user.User) error
+ SetVaultItem(ctx context.Context, email string, item vault.Item) error
+ ListVaultItems(ctx context.Context, email string, since int64) ([]vault.Item, error)
+ Close() error
+}
+
+type KvStorage interface {
+ Get(ctx context.Context, key string) (string, error)
+ Set(ctx context.Context, key string, value string, expiration time.Duration) error
+ Delete(ctx context.Context, key string) error
+ Close() error
+}
+
+type mailSender interface {
+ Send(context.Context, *mail.Mail) error
+ Validate(email string) error
+}
+
+type Option func(*Service)
+
+type caches struct {
+ auth KvStorage
+ mail KvStorage
+ lastUpdate KvStorage
+}
+
+type Service struct {
+ cfg scfg.Config
+ storage Storage
+ caches caches
+ rtaskClient rtask.Client
+ mailSender mailSender
+ logger *slog.Logger
+}
+
+func New(cfg scfg.Config,
+ storage Storage,
+ rtaskClient rtask.Client,
+ mailSender mailSender,
+ options ...Option) (*Service, error) {
+ s := &Service{
+ cfg: cfg,
+ storage: storage,
+ rtaskClient: rtaskClient,
+ mailSender: mailSender,
+ }
+
+ for _, opt := range options {
+ opt(s)
+ }
+
+ if s.logger == nil {
+ s.logger = slog.New(
+ slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
+ )
+ }
+
+ if s.caches.auth == nil {
+ s.caches.auth = smap.New(30 * time.Minute)
+ }
+ if s.caches.mail == nil {
+ s.caches.mail = smap.New(30 * time.Minute)
+ }
+ if s.caches.lastUpdate == nil {
+ s.caches.lastUpdate = smap.New(30 * time.Minute)
+ }
+
+ s.logger = s.logger.With("from", "service")
+
+ return s, nil
+}
+
+func WithSLogger(logger *slog.Logger) Option {
+ return func(s *Service) {
+ s.logger = logger
+ }
+}
+
+func WithAuthCache(cache KvStorage) Option {
+ return func(s *Service) {
+ s.caches.auth = cache
+ }
+}
+
+func WithMailCache(cache KvStorage) Option {
+ return func(s *Service) {
+ s.caches.mail = cache
+ }
+}
+
+func WithLastUpdateCache(cache KvStorage) Option {
+ return func(s *Service) {
+ s.caches.lastUpdate = cache
+ }
+}
diff --git a/server/internal/service/task/task.go b/server/internal/service/task/task.go
new file mode 100644
index 0000000..7cfdf03
--- /dev/null
+++ b/server/internal/service/task/task.go
@@ -0,0 +1,30 @@
+package task
+
+import (
+ "github.com/goccy/go-json"
+
+ "github.com/hibiken/asynq"
+)
+
+// A list of task types.
+const (
+ TypeWelcomeVerificationEmail = "email:welcome_verification"
+)
+
+// ErrSkipRetry is used as a return value from handler to indicate that
+// the task should not be retried and should be archived instead.
+var ErrSkipRetry = asynq.SkipRetry
+
+// EmailTaskPayload is the payload of all email tasks.
+type EmailTaskPayload struct {
+ Email string
+}
+
+// NewVerificationEmailTask creates a new verification email task.
+func NewWelcomeVerificationEmailTask(email, code string) (*asynq.Task, error) {
+ payload, err := json.Marshal(EmailTaskPayload{Email: email})
+ if err != nil {
+ return nil, err
+ }
+ return asynq.NewTask(TypeWelcomeVerificationEmail, payload), nil
+}
diff --git a/server/internal/service/user.go b/server/internal/service/user.go
new file mode 100644
index 0000000..14769d9
--- /dev/null
+++ b/server/internal/service/user.go
@@ -0,0 +1,202 @@
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "time"
+
+ "log/slog"
+
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ "github.com/Karzoug/goph_keeper/server/internal/model/auth/token"
+ "github.com/Karzoug/goph_keeper/server/internal/model/user"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/storage"
+ "github.com/Karzoug/goph_keeper/server/internal/service/task"
+)
+
+const emailSendingTimeout = 3 * time.Second
+
+// Register registers a new user.
+func (s *Service) Register(ctx context.Context, email string, hash []byte) error {
+ const op = "service: register user"
+
+ u, err := user.New(email, hash)
+ if err != nil {
+ switch {
+ case errors.Is(err, user.ErrInvalidEmailFormat):
+ return ErrInvalidEmailFormat
+ case errors.Is(err, user.ErrInvalidHashFormat):
+ return ErrInvalidHashFormat
+ default:
+ return e.Wrap(op, err)
+ }
+ }
+
+ code, err := generateNumericCode(s.cfg.Email.CodeLength)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ err = s.caches.mail.Set(ctx, u.Email, code, s.cfg.Email.CodeLifetime)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ err = s.storage.AddUser(ctx, u)
+ if err != nil {
+ switch {
+ case errors.Is(err, storage.ErrRecordAlreadyExists):
+ return ErrUserAlreadyExists
+ default:
+ return e.Wrap(op, err)
+ }
+ }
+
+ tsk, err := task.NewWelcomeVerificationEmailTask(u.Email, code)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+ err = s.rtaskClient.Enqueue(tsk, emailSendingTimeout)
+ if err != nil {
+ return e.Wrap(op, err)
+ }
+
+ s.logger.Debug("user successfully added to storage", slog.String("email", u.Email))
+
+ return nil
+}
+
+// Login logs in a user.
+func (s *Service) Login(ctx context.Context, email string, hash []byte) (string, error) {
+ const op = "service: login user"
+
+ u, err := s.getUser(ctx, email, hash)
+ if err != nil {
+ return "", e.Wrap(op, err)
+ }
+
+ if !u.IsEmailVerified {
+ return "", ErrUserEmailNotVerified
+ }
+
+ tokenString, err := s.setUserToAuthCache(ctx, u)
+ if err != nil {
+ return "", e.Wrap(op, err)
+ }
+
+ return tokenString, nil
+}
+
+// LoginWithEmailCode logs in a user if user needs verification.
+func (s *Service) LoginWithEmailCode(ctx context.Context, email string, hash []byte, code string) (string, error) {
+ const op = "service: login user with email code"
+
+ u, err := s.getUser(ctx, email, hash)
+ if err != nil {
+ return "", e.Wrap(op, err)
+ }
+
+ ccode, err := s.caches.mail.Get(ctx, email)
+ if err != nil {
+ return "", e.Wrap(op, err)
+ }
+
+ if ccode != code {
+ return "", ErrUserEmailNotVerified
+ }
+
+ u.IsEmailVerified = true
+
+ err = s.storage.UpdateUser(ctx, u)
+ if err != nil {
+ return "", e.Wrap(op, err)
+ }
+
+ _ = s.caches.mail.Delete(ctx, email)
+
+ tokenString, err := s.setUserToAuthCache(ctx, u)
+ if err != nil {
+ return "", e.Wrap(op, err)
+ }
+ return tokenString, nil
+}
+
+// AuthUser verifies user's token and returns the email if success.
+func (s *Service) AuthUser(ctx context.Context, tokenString string) (string, error) {
+ const op = "service: auth user"
+
+ token, err := token.FromString(tokenString, s.cfg.Token.SecretKey)
+ if err != nil {
+ return "", e.Wrap(op, ErrInvalidTokenFormat)
+ }
+ if token.IsExpired() {
+ return "", e.Wrap(op, ErrUserNeedAuthentication)
+ }
+
+ email, err := s.caches.auth.Get(ctx, token.ID())
+ if err != nil {
+ if errors.Is(err, storage.ErrRecordNotFound) {
+ return "", e.Wrap(op, ErrUserNeedAuthentication)
+ }
+ return "", e.Wrap(op, err)
+ }
+
+ return email, nil
+}
+
+// getUser returns user by email and auth hash.
+func (s *Service) getUser(ctx context.Context, email string, authHash []byte) (user.User, error) {
+ const op = "get user"
+
+ u, err := user.New(email, authHash)
+ if err != nil {
+ switch {
+ case errors.Is(err, user.ErrInvalidEmailFormat):
+ return user.User{}, ErrInvalidEmailFormat
+ case errors.Is(err, user.ErrInvalidHashFormat):
+ return user.User{}, ErrInvalidHashFormat
+ default:
+ return user.User{}, e.Wrap(op, err)
+ }
+ }
+
+ u, err = s.storage.GetUser(ctx, email)
+ if err != nil {
+ if errors.Is(err, storage.ErrRecordNotFound) {
+ return user.User{}, ErrUserNotExists
+ }
+ return user.User{}, e.Wrap(op, err)
+ }
+ if !u.AuthKey.Verify(authHash) {
+ return user.User{}, ErrUserInvalidHash
+ }
+ return u, nil
+}
+
+// setUserToAuthCache adds user email to auth cache and returns token.
+func (s *Service) setUserToAuthCache(ctx context.Context, u user.User) (string, error) {
+ eTime := time.Now().Add(s.cfg.Token.TokenLifetime)
+
+ t := token.New(eTime, s.cfg.Token.SecretKey)
+ err := s.caches.auth.Set(ctx, t.ID(), u.Email, time.Until(eTime))
+ if err != nil {
+ return "", err
+ }
+
+ return t.String(), nil
+}
+
+func generateNumericCode(n int) (string, error) {
+ const op = "generate numeric code"
+
+ rnd := make([]byte, n)
+ _, err := rand.Read(rnd)
+ if err != nil {
+ return "", e.Wrap(op, err)
+ }
+ for i := range rnd {
+ rnd[i] = '0' + rnd[i]%10
+ }
+ return string(rnd), nil
+}
diff --git a/server/internal/service/vault.go b/server/internal/service/vault.go
new file mode 100644
index 0000000..261a409
--- /dev/null
+++ b/server/internal/service/vault.go
@@ -0,0 +1,82 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "strconv"
+ "time"
+
+ "github.com/Karzoug/goph_keeper/common/model/vault"
+ "github.com/Karzoug/goph_keeper/pkg/e"
+ "github.com/Karzoug/goph_keeper/pkg/logger/slog/sl"
+ "github.com/Karzoug/goph_keeper/server/internal/repository/storage"
+)
+
+const lastUpdateCacheTTL = 30 * time.Minute
+
+func (s *Service) SetVaultItem(ctx context.Context, email string, item vault.Item) (int64, error) {
+ const op = "service: set vault item"
+
+ if len(item.Value) > int(s.cfg.StorageMaxSizeItemValue) {
+ return 0, e.Wrap(op, ErrVaultItemValueTooBig)
+ }
+
+ item.ClientUpdatedAt = time.Now().UnixMicro()
+
+ err := s.storage.SetVaultItem(ctx, email, item)
+ if err != nil {
+ if errors.Is(err, storage.ErrNoRecordsAffected) {
+ return 0, e.Wrap(op, ErrVaultItemVersionConflict)
+ }
+ return 0, e.Wrap(op, err)
+ }
+
+ if err := s.caches.lastUpdate.Set(ctx, email, strconv.FormatInt(item.ClientUpdatedAt, 10), lastUpdateCacheTTL); err != nil {
+ s.logger.Warn(op, e.Wrap(op, err))
+ }
+
+ return item.ClientUpdatedAt, nil
+}
+
+func (s *Service) ListVaultItems(ctx context.Context, email string, since int64) ([]vault.Item, error) {
+ const op = "service: list vault items"
+
+ // first try to find in cache if there is since date
+ if since != 0 {
+ str, err := s.caches.lastUpdate.Get(ctx, email)
+ if err != nil {
+ if !errors.Is(err, storage.ErrRecordNotFound) {
+ s.logger.Error(op, sl.Error(err))
+ }
+ } else {
+ t, err := strconv.ParseInt(str, 10, 64)
+ if err == nil {
+ // if time in cache (on server) is older or equal than given since date
+ // then return empty slice of items
+ if since >= t {
+ return make([]vault.Item, 0), nil
+ }
+ }
+ }
+ }
+
+ items, err := s.storage.ListVaultItems(ctx, email, since)
+ if err != nil {
+ return nil, e.Wrap(op, err)
+ }
+
+ var oTime int64
+ for i := 0; i < len(items); i++ {
+ if items[i].ServerUpdatedAt > oTime {
+ oTime = items[i].ServerUpdatedAt
+ }
+ }
+
+ if oTime == 0 {
+ return items, nil
+ }
+ if err := s.caches.lastUpdate.Set(ctx, email, strconv.FormatInt(oTime, 10), lastUpdateCacheTTL); err != nil {
+ s.logger.Warn(op, e.Wrap(op, err))
+ }
+ return items, nil
+}
diff --git a/server/migrations/postgres/000001_create_users_table.down.sql b/server/migrations/postgres/000001_create_users_table.down.sql
new file mode 100644
index 0000000..441087a
--- /dev/null
+++ b/server/migrations/postgres/000001_create_users_table.down.sql
@@ -0,0 +1 @@
+DROP TABLE users;
\ No newline at end of file
diff --git a/server/migrations/postgres/000001_create_users_table.up.sql b/server/migrations/postgres/000001_create_users_table.up.sql
new file mode 100644
index 0000000..b19c27b
--- /dev/null
+++ b/server/migrations/postgres/000001_create_users_table.up.sql
@@ -0,0 +1,5 @@
+CREATE TABLE IF NOT EXISTS users (
+ email TEXT PRIMARY KEY,
+ is_email_verified BOOLEAN,
+ auth_key bytea,
+ created_at timestamp);
\ No newline at end of file
diff --git a/server/migrations/postgres/000002_create_vaults_table.down.sql b/server/migrations/postgres/000002_create_vaults_table.down.sql
new file mode 100644
index 0000000..e570c4e
--- /dev/null
+++ b/server/migrations/postgres/000002_create_vaults_table.down.sql
@@ -0,0 +1 @@
+DROP TABLE vaults;
\ No newline at end of file
diff --git a/server/migrations/postgres/000002_create_vaults_table.up.sql b/server/migrations/postgres/000002_create_vaults_table.up.sql
new file mode 100644
index 0000000..7a8809c
--- /dev/null
+++ b/server/migrations/postgres/000002_create_vaults_table.up.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS vaults (
+ id TEXT NOT NULL,
+ email TEXT NOT NULL REFERENCES users (email),
+ name TEXT NOT NULL,
+ type INTEGER,
+ value bytea,
+ updated_at bigint,
+ PRIMARY KEY(id,email));
\ No newline at end of file
diff --git a/server/migrations/postgres/000003_delete_vault_item.down.sql b/server/migrations/postgres/000003_delete_vault_item.down.sql
new file mode 100644
index 0000000..63bd961
--- /dev/null
+++ b/server/migrations/postgres/000003_delete_vault_item.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE vaults
+DROP COLUMN is_deleted;
\ No newline at end of file
diff --git a/server/migrations/postgres/000003_delete_vault_item.up.sql b/server/migrations/postgres/000003_delete_vault_item.up.sql
new file mode 100644
index 0000000..473c765
--- /dev/null
+++ b/server/migrations/postgres/000003_delete_vault_item.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE vaults
+ADD is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
\ No newline at end of file
diff --git a/server/migrations/sqlite/000001_create_users_table.down.sql b/server/migrations/sqlite/000001_create_users_table.down.sql
new file mode 100644
index 0000000..441087a
--- /dev/null
+++ b/server/migrations/sqlite/000001_create_users_table.down.sql
@@ -0,0 +1 @@
+DROP TABLE users;
\ No newline at end of file
diff --git a/server/migrations/sqlite/000001_create_users_table.up.sql b/server/migrations/sqlite/000001_create_users_table.up.sql
new file mode 100644
index 0000000..b957de6
--- /dev/null
+++ b/server/migrations/sqlite/000001_create_users_table.up.sql
@@ -0,0 +1,5 @@
+CREATE TABLE IF NOT EXISTS users (
+ email TEXT PRIMARY KEY,
+ is_email_verified INTEGER,
+ auth_key BLOB,
+ created_at DATETIME);
\ No newline at end of file
diff --git a/server/migrations/sqlite/000002_create_vaults_table.down.sql b/server/migrations/sqlite/000002_create_vaults_table.down.sql
new file mode 100644
index 0000000..e570c4e
--- /dev/null
+++ b/server/migrations/sqlite/000002_create_vaults_table.down.sql
@@ -0,0 +1 @@
+DROP TABLE vaults;
\ No newline at end of file
diff --git a/server/migrations/sqlite/000002_create_vaults_table.up.sql b/server/migrations/sqlite/000002_create_vaults_table.up.sql
new file mode 100644
index 0000000..d79c83e
--- /dev/null
+++ b/server/migrations/sqlite/000002_create_vaults_table.up.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS vaults (
+ id TEXT NOT NULL,
+ email TEXT NOT NULL REFERENCES users (email),
+ name TEXT NOT NULL,
+ type INTEGER,
+ value BLOB,
+ updated_at INTEGER,
+ PRIMARY KEY(id,email));
\ No newline at end of file
diff --git a/server/migrations/sqlite/000003_delete_vault_item.down.sql b/server/migrations/sqlite/000003_delete_vault_item.down.sql
new file mode 100644
index 0000000..63bd961
--- /dev/null
+++ b/server/migrations/sqlite/000003_delete_vault_item.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE vaults
+DROP COLUMN is_deleted;
\ No newline at end of file
diff --git a/server/migrations/sqlite/000003_delete_vault_item.up.sql b/server/migrations/sqlite/000003_delete_vault_item.up.sql
new file mode 100644
index 0000000..7063a69
--- /dev/null
+++ b/server/migrations/sqlite/000003_delete_vault_item.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE vaults
+ADD is_deleted INTEGER NOT NULL DEFAULT 0;
\ No newline at end of file
diff --git a/test/auth_test.go b/test/auth_test.go
new file mode 100644
index 0000000..49de216
--- /dev/null
+++ b/test/auth_test.go
@@ -0,0 +1,134 @@
+package main
+
+// Basic imports
+import (
+ "context"
+ "crypto/rand"
+ "time"
+
+ "github.com/pioz/faker"
+
+ pb "github.com/Karzoug/goph_keeper/common/grpc"
+)
+
+type AuthSuite struct {
+ commonTestSuite
+}
+
+func (suite *AuthSuite) TestAuth() {
+ ctx, cancel := context.WithTimeout(context.Background(), testsTimeout)
+ defer cancel()
+
+ suite.Run("register: bad arguments", func() {
+ authHash := make([]byte, 32)
+ _, err := rand.Read(authHash)
+ suite.Require().NoError(err, "Generate random auth hash error")
+
+ // the same email
+ _, err = suite.grpcClient.Register(ctx, &pb.RegisterRequest{
+ Email: suite.email,
+ Hash: authHash,
+ })
+ suite.Assert().ErrorIs(err, pb.ErrUserAlreadyExists)
+
+ // bad email
+ _, err = suite.grpcClient.Register(ctx, &pb.RegisterRequest{
+ Email: faker.Username() + "@superpupkinmupkin.io",
+ Hash: authHash,
+ })
+ suite.Assert().ErrorIs(err, pb.ErrInvalidEmailFormat)
+
+ // bad email
+ _, err = suite.grpcClient.Register(ctx, &pb.RegisterRequest{
+ Email: faker.Username() + "io.com",
+ Hash: authHash,
+ })
+ suite.Assert().ErrorIs(err, pb.ErrInvalidEmailFormat)
+
+ // empty email
+ _, err = suite.grpcClient.Register(ctx, &pb.RegisterRequest{
+ Email: "",
+ Hash: authHash,
+ })
+ suite.Assert().ErrorIs(err, pb.ErrInvalidEmailFormat)
+
+ // empty hash
+ _, err = suite.grpcClient.Register(ctx, &pb.RegisterRequest{
+ Email: faker.SafeEmail(),
+ Hash: []byte{},
+ })
+ suite.Assert().ErrorIs(err, pb.ErrInvalidHashFormat)
+ })
+
+ suite.Run("login: bad arguments", func() {
+ // email/user not exist
+ _, err := suite.grpcClient.Login(ctx, &pb.LoginRequest{
+ Email: faker.FreeEmail(),
+ Hash: suite.authHash,
+ })
+ suite.Assert().ErrorIs(err, pb.ErrUserNotExists)
+
+ // bad email
+ _, err = suite.grpcClient.Login(ctx, &pb.LoginRequest{
+ Email: faker.Username() + "@superpupkinmupkin.io",
+ Hash: suite.authHash,
+ })
+ suite.Assert().ErrorIs(err, pb.ErrInvalidEmailFormat)
+
+ // bad email
+ _, err = suite.grpcClient.Login(ctx, &pb.LoginRequest{
+ Email: faker.Username() + "io.com",
+ Hash: suite.authHash,
+ })
+ suite.Assert().ErrorIs(err, pb.ErrInvalidEmailFormat)
+
+ // empty email
+ _, err = suite.grpcClient.Login(ctx, &pb.LoginRequest{
+ Email: "",
+ Hash: suite.authHash,
+ })
+ suite.Assert().ErrorIs(err, pb.ErrInvalidEmailFormat)
+
+ // empty hash
+ _, err = suite.grpcClient.Login(ctx, &pb.LoginRequest{
+ Email: faker.SafeEmail(),
+ Hash: []byte{},
+ })
+ suite.Assert().ErrorIs(err, pb.ErrInvalidHashFormat)
+ })
+
+ suite.Run("get items: bad auth", func() {
+ _, err := suite.grpcClient.ListVaultItems(ctx, &pb.ListVaultItemsRequest{})
+ suite.Assert().ErrorIs(err, pb.ErrEmptyAuthData)
+ })
+
+ suite.Run("restart server with token lifetime equals 2 sec", func() {
+ suite.serverDown()
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ envs := make([]string, len(suite.envs)+1)
+ copy(envs, suite.envs)
+ envs = append(envs, "GOPHKEEPER_SERVICE_TOKEN_LIFETIME=2s")
+
+ suite.serverUp(ctx, envs)
+ })
+
+ suite.Run("token lifetime work check", func() {
+ // login with the verification code, got token
+ resp, err := suite.grpcClient.Login(ctx, &pb.LoginRequest{
+ Email: suite.email,
+ Hash: suite.authHash,
+ })
+ suite.Require().NoError(err, "gRPC existed user login error", err)
+ suite.Assert().NotEqual(0, len(resp.Token), "Token not found in response")
+
+ ctx := newContextWithAuthData(ctx, resp.Token)
+ _, err = suite.grpcClient.ListVaultItems(ctx, &pb.ListVaultItemsRequest{})
+ suite.Assert().NoError(err, "Login with valid token error", err)
+
+ time.Sleep(3 * time.Second)
+ _, err = suite.grpcClient.ListVaultItems(ctx, &pb.ListVaultItemsRequest{})
+ suite.Assert().ErrorIs(err, pb.ErrUserNeedAuthentication)
+ })
+}
diff --git a/test/common_test.go b/test/common_test.go
new file mode 100644
index 0000000..5cb9cb8
--- /dev/null
+++ b/test/common_test.go
@@ -0,0 +1,297 @@
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/buger/jsonparser"
+ "github.com/pioz/faker"
+ "github.com/stretchr/testify/suite"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+
+ pb "github.com/Karzoug/goph_keeper/common/grpc"
+ "github.com/Karzoug/goph_keeper/test/internal/fork"
+)
+
+type commonTestSuite struct {
+ suite.Suite
+
+ host string
+ port string
+ mailpitHost string
+ mailpitPort string
+ redisHost string
+ redisPort string
+ binaryPath string
+ envs []string
+
+ conn *grpc.ClientConn
+ grpcClient pb.GophKeeperServiceClient
+ authHash []byte
+ email string
+ token string
+
+ serverProcess *fork.BackgroundProcess
+}
+
+// SetupSuite bootstraps suite dependencies
+func (suite *commonTestSuite) SetupSuite() {
+ err := suite.getEnvs()
+ if err != nil {
+ suite.T().Errorf("Невозможно запустить тесты, не установлены переменные окружения: %s", err.Error())
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ err = fork.WaitPort(ctx, "tcp", suite.redisHost+":"+suite.redisPort)
+ if err != nil {
+ suite.T().Errorf("Не удалось дождаться пока порт %s станет доступен для запроса: %s", suite.redisPort, err)
+ return
+ }
+ err = fork.WaitPort(ctx, "tcp", suite.mailpitHost+":"+suite.mailpitPort)
+ if err != nil {
+ suite.T().Errorf("Не удалось дождаться пока порт %s станет доступен для запроса: %s", suite.mailpitPort, err)
+ return
+ }
+
+ suite.envs = os.Environ()
+ suite.envs = append(suite.envs, "GOPHKEEPER_SERVICE_TOKEN_SECRET_KEY="+faker.StringWithSize(20))
+ suite.serverUp(ctx, suite.envs)
+
+ config := &tls.Config{
+ ServerName: suite.host,
+ InsecureSkipVerify: true,
+ MinVersion: tls.VersionTLS13,
+ }
+
+ addr := suite.host + ":" + suite.port
+ conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(credentials.NewTLS(config)), grpc.WithBlock())
+ if err != nil {
+ suite.Require().Error(err, "gRPC dial error")
+ return
+ }
+
+ grpcClient := pb.NewGophKeeperServiceClient(conn)
+ suite.conn = conn
+ suite.grpcClient = grpcClient
+
+ suite.registerAndLogin(ctx)
+}
+
+func (suite *commonTestSuite) serverUp(ctx context.Context, envs []string) {
+ p := fork.NewBackgroundProcess(context.Background(),
+ suite.binaryPath,
+ fork.WithEnv(envs...),
+ )
+
+ err := p.Start(ctx)
+ if err != nil {
+ suite.T().Errorf("Невозможно запустить процесс командой %s: %s. Переменные окружения: %+v", p, err, envs)
+ return
+ }
+
+ err = fork.WaitPort(ctx, "tcp", suite.host+":"+suite.port)
+ if err != nil {
+ suite.T().Errorf("Не удалось дождаться пока порт %s станет доступен для запроса: %s", suite.port, err)
+ if out := p.Stderr(ctx); len(out) > 0 {
+ suite.T().Logf("Получен STDERR лог агента:\n\n%s\n\n", string(out))
+ }
+ if out := p.Stdout(ctx); len(out) > 0 {
+ suite.T().Logf("Получен STDOUT лог агента:\n\n%s\n\n", string(out))
+ }
+ return
+ }
+
+ suite.serverProcess = p
+}
+
+func (suite *commonTestSuite) serverDown() {
+ if suite.serverProcess == nil {
+ return
+ }
+
+ exitCode, err := suite.serverProcess.Stop(syscall.SIGINT, syscall.SIGKILL)
+ if err != nil {
+ if errors.Is(err, os.ErrProcessDone) {
+ return
+ }
+ suite.T().Logf("Не удалось остановить процесс с помощью сигнала ОС: %s", err)
+ return
+ }
+
+ if exitCode > 0 {
+ suite.T().Logf("Процесс завершился с не нулевым статусом %d", exitCode)
+ }
+
+ // try to read stdout/stderr
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+
+ if out := suite.serverProcess.Stderr(ctx); len(out) > 0 {
+ suite.T().Logf("Получен STDERR лог агента:\n\n%s\n\n", string(out))
+ }
+ if out := suite.serverProcess.Stdout(ctx); len(out) > 0 {
+ suite.T().Logf("Получен STDOUT лог агента:\n\n%s\n\n", string(out))
+ }
+}
+
+func (suite *commonTestSuite) TearDownSuite() {
+ suite.serverDown()
+
+ // delete all mails in mailpit
+ req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("http://%s/api/v1/messages", suite.mailpitHost+":"+suite.mailpitPort), http.NoBody)
+ if err != nil {
+ suite.T().Logf("Create request error: %s", err)
+ return
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ suite.T().Logf("Delete all mails from mailpit error: %s", err)
+ return
+ }
+ defer resp.Body.Close()
+}
+
+func (suite *commonTestSuite) getEnvs() error {
+ var ok bool
+ if suite.host, ok = os.LookupEnv("TEST_GRPC_HOST"); !ok {
+ return errors.New("TEST_GRPC_HOST not set")
+ }
+ if suite.port, ok = os.LookupEnv("TEST_GRPC_PORT"); !ok {
+ return errors.New("TEST_GRPC_PORT not set")
+ }
+ if suite.mailpitHost, ok = os.LookupEnv("TEST_MAIL_HOST"); !ok {
+ return errors.New("TEST_MAIL_HOST not set")
+ }
+ if suite.mailpitPort, ok = os.LookupEnv("TEST_MAIL_PORT"); !ok {
+ return errors.New("TEST_MAIL_PORT not set")
+ }
+ if suite.redisHost, ok = os.LookupEnv("TEST_REDIS_HOST"); !ok {
+ return errors.New("TEST_REDIS_HOST not set")
+ }
+ if suite.redisPort, ok = os.LookupEnv("TEST_REDIS_PORT"); !ok {
+ return errors.New("TEST_REDIS_PORT not set")
+ }
+ if suite.binaryPath, ok = os.LookupEnv("TEST_BINARY_PATH"); !ok {
+ return errors.New("TEST_BINARY_PATH not set")
+ }
+
+ return nil
+}
+
+func (suite *commonTestSuite) registerAndLogin(ctx context.Context) {
+ // generate random auth hash
+ suite.authHash = make([]byte, 32)
+ _, err := rand.Read(suite.authHash)
+ suite.Require().NoError(err, "Generate random auth hash error")
+ suite.T().Logf("Generate random auth hash: len %d", len(suite.authHash))
+
+ suite.email = faker.SafeEmail()
+ suite.T().Logf("Generate random email: %s", suite.email)
+
+ _, err = suite.grpcClient.Register(ctx, &pb.RegisterRequest{
+ Email: suite.email,
+ Hash: suite.authHash,
+ })
+ suite.Require().NoError(err, "gRPC user register error", err)
+
+ // try to login, email not confirmed
+ _, err = suite.grpcClient.Login(ctx, &pb.LoginRequest{
+ Email: suite.email,
+ Hash: suite.authHash,
+ })
+ suite.Require().ErrorIs(err, pb.ErrUserEmailNotVerified)
+
+ getIdLastMail := func(toEmail string) ([]byte, error) {
+ resp, err := http.Get(fmt.Sprintf("http://%s/api/v1/search?query=to:\"%s\"&limit=1", suite.mailpitHost+":"+suite.mailpitPort, toEmail))
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.New("code not found")
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ return body, nil
+ }
+
+ getBodyLastMail := func(id string) ([]byte, error) {
+ resp, err := http.Get(fmt.Sprintf("http://%s/api/v1/message/%s", suite.mailpitHost+":"+suite.mailpitPort, id))
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.New("code not found")
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ return body, nil
+ }
+
+ // waiting for the mail
+ var body []byte
+ for attempt := 0; attempt < 20; attempt++ {
+ body, err = getIdLastMail(suite.email)
+ if err != nil {
+ time.Sleep(500 * time.Millisecond)
+ continue
+ }
+
+ var count int64
+ count, err = jsonparser.GetInt(body, "messages_count")
+ if err != nil || count == 0 {
+ time.Sleep(500 * time.Millisecond)
+ continue
+ }
+ }
+ suite.Require().NoError(err, "Not found any mails in mailpit to the email %s", suite.email)
+ id, err := jsonparser.GetString(body, "messages", "[0]", "ID")
+ suite.Require().NoError(err, "Wrong response format from mailpit")
+
+ body, err = getBodyLastMail(id)
+ suite.Require().NoError(err, "Not found mail by id in mailpit")
+
+ text, err := jsonparser.GetString(body, "Text")
+ suite.Require().NoError(err, "Wrong response format from mailpit")
+
+ // looking for the verification code in the mail
+ substr := `activate your account: `
+ pos := strings.Index(text, substr)
+ suite.Require().NotEqual(-1, pos, "Verification code not found in mail")
+ codeInMail := text[pos+len(substr) : pos+len(substr)+emailCodeLength]
+
+ suite.T().Logf("Found verification code in mail: %s", codeInMail)
+
+ // login with the verification code, got token
+ resp, err := suite.grpcClient.Login(ctx, &pb.LoginRequest{
+ Email: suite.email,
+ Hash: suite.authHash,
+ EmailCode: codeInMail,
+ })
+ suite.Require().NoError(err, "gRPC user login with mail verification code error", err)
+ suite.Assert().NotEqual(0, len(resp.Token), "Token not found in response")
+
+ suite.token = resp.Token
+}
diff --git a/test/go.mod b/test/go.mod
new file mode 100644
index 0000000..0b1c28b
--- /dev/null
+++ b/test/go.mod
@@ -0,0 +1,26 @@
+module github.com/Karzoug/goph_keeper/test
+
+go 1.21
+
+toolchain go1.21.0
+
+require (
+ github.com/Karzoug/goph_keeper/common v0.7.1
+ github.com/buger/jsonparser v1.1.1
+ github.com/pioz/faker v1.7.3
+ github.com/rs/xid v1.5.0
+ github.com/stretchr/testify v1.8.4
+ google.golang.org/grpc v1.57.0
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/golang/protobuf v1.5.3 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ golang.org/x/net v0.9.0 // indirect
+ golang.org/x/sys v0.7.0 // indirect
+ golang.org/x/text v0.9.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19 // indirect
+ google.golang.org/protobuf v1.30.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/test/go.sum b/test/go.sum
new file mode 100644
index 0000000..36ac206
--- /dev/null
+++ b/test/go.sum
@@ -0,0 +1,43 @@
+github.com/Karzoug/goph_keeper/common v0.7.1 h1:lIM93VgdvjwfYuq65j45YPY/aeYkXALkmXrsIyElprE=
+github.com/Karzoug/goph_keeper/common v0.7.1/go.mod h1:LkSZ9pS4W6GdXG8ARiFkCgzugvJbfoLNdvdrThzUKII=
+github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+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/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/go-cmp v0.5.5/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/pioz/faker v1.7.3 h1:Tez8Emuq0UN+/d6mo3a9m/9ZZ/zdfJk0c5RtRatrceM=
+github.com/pioz/faker v1.7.3/go.mod h1:xSpay5w/oz1a6+ww0M3vfpe40pSIykeUPeWEc3TvVlc=
+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/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
+github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
+golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
+golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
+golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19 h1:0nDDozoAU19Qb2HwhXadU8OcsiO/09cnTqhUtq2MEOM=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
+google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
+google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
+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.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
+google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/test/internal/fork/buffer.go b/test/internal/fork/buffer.go
new file mode 100644
index 0000000..d7ba141
--- /dev/null
+++ b/test/internal/fork/buffer.go
@@ -0,0 +1,33 @@
+package fork
+
+import (
+ "bytes"
+ "sync"
+)
+
+// buffer вяляется синхронной оберткой над bytes.Buffer
+type buffer struct {
+ m sync.RWMutex
+ buf bytes.Buffer
+}
+
+// Write реализует интерфейс io.Writer
+func (b *buffer) Write(p []byte) (n int, err error) {
+ b.m.Lock()
+ defer b.m.Unlock()
+ return b.buf.Write(p)
+}
+
+// Read реализует интерфейс io.Reader
+func (b *buffer) Read(p []byte) (n int, err error) {
+ b.m.RLock()
+ defer b.m.RUnlock()
+ return b.buf.Read(p)
+}
+
+// Bytes возвращает все байты из буфера
+func (b *buffer) Bytes() []byte {
+ b.m.RLock()
+ defer b.m.RUnlock()
+ return b.buf.Bytes()
+}
diff --git a/test/internal/fork/process.go b/test/internal/fork/process.go
new file mode 100644
index 0000000..7e6ffb5
--- /dev/null
+++ b/test/internal/fork/process.go
@@ -0,0 +1,148 @@
+package fork
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+const (
+ waitPortInterval time.Duration = 100 * time.Millisecond
+ waitPortConnTimeout time.Duration = 50 * time.Millisecond
+)
+
+// BackgroundProcess является удобной оберткой над exec.Cmd
+// для работы с запущенными процессами
+type BackgroundProcess struct {
+ cmd *exec.Cmd
+ stdout *buffer
+ stderr *buffer
+}
+
+// NewBackgroundProcess returns new unstarted background process instance.
+func NewBackgroundProcess(ctx context.Context, command string, opts ...ProcessOpt) *BackgroundProcess {
+ p := &BackgroundProcess{
+ cmd: exec.CommandContext(ctx, command),
+ }
+
+ for _, opt := range opts {
+ opt(p)
+ }
+
+ p.stdout = new(buffer)
+ p.cmd.Stdout = p.stdout
+ p.stderr = new(buffer)
+ p.cmd.Stderr = p.stdout
+
+ return p
+}
+
+// Start является аналогом (*exec.Cmd).Start с поддержкой контекста
+func (p *BackgroundProcess) Start(ctx context.Context) error {
+ startChan := make(chan error, 1)
+ go func() {
+ startChan <- p.cmd.Start()
+ }()
+
+ for {
+ select {
+ case err := <-startChan:
+ return err
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+}
+
+// WaitPort позволяет дождаться занятия порта процессом
+func WaitPort(ctx context.Context, network, address string) error {
+ ticker := time.NewTicker(waitPortInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-ticker.C:
+ conn, _ := net.DialTimeout(network, address, waitPortConnTimeout)
+ if conn != nil {
+ _ = conn.Close()
+ return nil
+ }
+ }
+ }
+}
+
+// ListenPort позволяет проверить наличие свободного порта
+func ListenPort(ctx context.Context, network, port string) error {
+ ticker := time.NewTicker(waitPortInterval)
+ defer ticker.Stop()
+
+ port = strings.TrimLeft(port, ":")
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-ticker.C:
+ lc := &net.ListenConfig{}
+ ln, _ := lc.Listen(ctx, network, ":"+port)
+ if ln != nil {
+ defer ln.Close()
+ done := make(chan struct{})
+ go func() {
+ conn, _ := ln.Accept()
+ if conn != nil {
+ _ = conn.Close()
+ }
+ close(done)
+ }()
+ select {
+ case <-done:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+ }
+ }
+}
+
+// Stdout вычитывает и возвращает новый блок данных из stdout
+func (p *BackgroundProcess) Stdout(ctx context.Context) []byte {
+ return p.stdout.Bytes()
+}
+
+// Stderr вычитывает и возвращает новый блок данных из stderr
+func (p *BackgroundProcess) Stderr(ctx context.Context) []byte {
+ return p.stderr.Bytes()
+}
+
+// Stop пытается остановить процесс последовательной передачей процессу данных сигналов
+func (p *BackgroundProcess) Stop(signals ...os.Signal) (exitCode int, err error) {
+ for _, sig := range signals {
+ err = p.cmd.Process.Signal(sig)
+ if err == nil {
+ break
+ }
+ }
+
+ if err != nil {
+ return -1, fmt.Errorf("error sending signal to process: %w", err)
+ }
+
+ state, err := p.cmd.Process.Wait()
+ if state == nil {
+ return -1, err
+ }
+ return state.ExitCode(), err
+}
+
+// String возвращает человекочитаемую команду, которая породила процесс
+func (p *BackgroundProcess) String() string {
+ return p.cmd.String()
+}
diff --git a/test/internal/fork/process_opts.go b/test/internal/fork/process_opts.go
new file mode 100644
index 0000000..946f0a0
--- /dev/null
+++ b/test/internal/fork/process_opts.go
@@ -0,0 +1,17 @@
+package fork
+
+type ProcessOpt = func(p *BackgroundProcess)
+
+// WithEnv добавляет переменные окружения вида KEY=VALUE процессу
+func WithEnv(env ...string) ProcessOpt {
+ return func(p *BackgroundProcess) {
+ p.cmd.Env = append(p.cmd.Env, env...)
+ }
+}
+
+// WithArgs добавляет процессу аргументы командной строки
+func WithArgs(args ...string) ProcessOpt {
+ return func(p *BackgroundProcess) {
+ p.cmd.Args = append(p.cmd.Args, args...)
+ }
+}
diff --git a/test/main_test.go b/test/main_test.go
new file mode 100644
index 0000000..6f3335a
--- /dev/null
+++ b/test/main_test.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/suite"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+
+ pb "github.com/Karzoug/goph_keeper/common/grpc"
+)
+
+const (
+ testsTimeout time.Duration = 120 * time.Second
+ emailCodeLength = 6
+)
+
+type client struct {
+ conn *grpc.ClientConn
+ grpcClient pb.GophKeeperServiceClient
+}
+
+func TestMain(m *testing.M) {
+ os.Exit(m.Run())
+}
+
+func TestAuth(t *testing.T) {
+ suite.Run(t, new(AuthSuite))
+}
+
+func TestVault(t *testing.T) {
+ suite.Run(t, new(VaultSuite))
+}
+
+func TestSyncVault(t *testing.T) {
+ suite.Run(t, new(SyncVaultSuite))
+}
+
+func newContextWithAuthData(ctx context.Context, token string) context.Context {
+ md := metadata.New(map[string]string{"token": token})
+ return metadata.NewOutgoingContext(ctx, md)
+}
diff --git a/test/sync_test.go b/test/sync_test.go
new file mode 100644
index 0000000..20f704c
--- /dev/null
+++ b/test/sync_test.go
@@ -0,0 +1,221 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/tls"
+ "encoding/gob"
+
+ "github.com/pioz/faker"
+ "github.com/rs/xid"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+
+ pb "github.com/Karzoug/goph_keeper/common/grpc"
+ "github.com/Karzoug/goph_keeper/common/model/vault"
+)
+
+type SyncVaultSuite struct {
+ commonTestSuite
+
+ token_2 string
+ conn_2 *grpc.ClientConn
+ grpcClient_2 pb.GophKeeperServiceClient
+}
+
+// SetupSuite bootstraps suite dependencies
+func (suite *SyncVaultSuite) SetupSuite() {
+ suite.commonTestSuite.SetupSuite()
+
+ // second client
+ config := &tls.Config{
+ ServerName: suite.host,
+ InsecureSkipVerify: true,
+ MinVersion: tls.VersionTLS13,
+ }
+ addr := suite.host + ":" + suite.port
+ conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(credentials.NewTLS(config)), grpc.WithBlock())
+ if err != nil {
+ suite.Require().Error(err, "gRPC dial error")
+ return
+ }
+ suite.conn_2 = conn
+ suite.grpcClient_2 = pb.NewGophKeeperServiceClient(conn)
+}
+
+func (suite *SyncVaultSuite) TestVault() {
+ ctx, cancel := context.WithTimeout(context.Background(), testsTimeout)
+ defer cancel()
+
+ itemIDs := make([]string, 3)
+ itemIDs[0] = xid.New().String()
+ itemIDs[1] = xid.New().String()
+ itemIDs[2] = xid.New().String()
+
+ var (
+ serverUpdatedAtForTextItem int64
+ lastUpdateFirstClient int64
+ )
+
+ suite.Run("add vault items", func() {
+ ctx := newContextWithAuthData(ctx, suite.token)
+
+ // text data
+ respSet, err := suite.grpcClient.SetVaultItem(ctx, &pb.SetVaultItemRequest{
+ Item: &pb.VaultItem{
+ Id: itemIDs[0],
+ Name: faker.String(),
+ Itype: pb.IType(vault.Text),
+ Value: []byte(faker.ArticleWithParagraphCount(faker.IntInRange(0, 10))),
+ },
+ })
+ suite.Require().NoError(err, "gRPC add vault item error", err)
+ serverUpdatedAtForTextItem = respSet.ServerUpdatedAt
+
+ // binary data
+ b := make([]byte, 50*1024)
+ _, err = rand.Read(b)
+ suite.Require().NoError(err)
+ respSet, err = suite.grpcClient.SetVaultItem(ctx, &pb.SetVaultItemRequest{
+ Item: &pb.VaultItem{
+ Id: itemIDs[1],
+ Name: faker.String(),
+ Itype: pb.IType(vault.Binary),
+ Value: b,
+ },
+ })
+ suite.Require().NoError(err, "gRPC add vault item error", err)
+
+ // encrypted card data
+ type Card struct {
+ Holder string
+ Expired string
+ Number string
+ CSC string
+ }
+ c := Card{
+ Holder: faker.FirstName() + " " + faker.LastName(),
+ Expired: faker.DigitsWithSize(2) + "/" + faker.DigitsWithSize(2),
+ Number: faker.DigitsWithSize(15),
+ CSC: faker.DigitsWithSize(4),
+ }
+ bb := bytes.NewBuffer(nil)
+ enc := gob.NewEncoder(bb)
+ err = enc.Encode(c)
+ suite.Require().NoError(err)
+
+ secret := make([]byte, 32)
+ _, err = rand.Read(b)
+ suite.Require().NoError(err)
+ data := encrypt(bb.Bytes(), secret)
+
+ respSet, err = suite.grpcClient.SetVaultItem(ctx, &pb.SetVaultItemRequest{
+ Item: &pb.VaultItem{
+ Id: itemIDs[2],
+ Name: faker.String(),
+ Itype: pb.IType(vault.Card),
+ Value: data,
+ },
+ })
+ suite.Require().NoError(err, "gRPC add vault item error", err)
+
+ lastUpdateFirstClient = respSet.ServerUpdatedAt
+ })
+
+ suite.Run("login into empty second client & sync", func() {
+ resp, err := suite.grpcClient.Login(ctx, &pb.LoginRequest{
+ Email: suite.email,
+ Hash: suite.authHash,
+ })
+ suite.Require().NoError(err, "gRPC user login with mail verification code error", err)
+ suite.Assert().NotEqual(0, len(resp.Token), "Token not found in response")
+
+ suite.token_2 = resp.Token
+
+ ctx := newContextWithAuthData(ctx, suite.token_2)
+
+ respList, err := suite.grpcClient_2.ListVaultItems(ctx, &pb.ListVaultItemsRequest{
+ Since: 0,
+ })
+ suite.Require().NoError(err, "gRPC list vault items error", err)
+ suite.Assert().Len(respList.Items, 3, "Wrong number of items in response")
+
+ lastUpdatedAt := respList.Items[0].ServerUpdatedAt
+ for i := 0; i < 3; i++ {
+ suite.Assert().Contains(itemIDs, respList.Items[i].Id)
+ if lastUpdatedAt < respList.Items[i].ServerUpdatedAt {
+ lastUpdatedAt = respList.Items[i].ServerUpdatedAt
+ }
+ }
+
+ respList, err = suite.grpcClient_2.ListVaultItems(ctx, &pb.ListVaultItemsRequest{
+ Since: lastUpdatedAt,
+ })
+ suite.Require().NoError(err, "gRPC list vault items error", err)
+ suite.Assert().Len(respList.Items, 0, "Wrong number of items in response")
+ })
+
+ itemIDs = append(itemIDs, xid.New().String())
+
+ suite.Run("add/edit vault items in second client & sync", func() {
+ ctx := newContextWithAuthData(ctx, suite.token_2)
+
+ // edit text data
+ _, err := suite.grpcClient_2.SetVaultItem(ctx, &pb.SetVaultItemRequest{
+ Item: &pb.VaultItem{
+ Id: itemIDs[0],
+ Name: faker.String(),
+ Itype: pb.IType(vault.Text),
+ Value: []byte(faker.ArticleWithParagraphCount(faker.IntInRange(0, 10))),
+ ServerUpdatedAt: serverUpdatedAtForTextItem,
+ },
+ })
+ suite.Require().NoError(err, "gRPC add vault item error", err)
+
+ // add new binary data
+ b := make([]byte, 50*1024)
+ _, err = rand.Read(b)
+ suite.Require().NoError(err)
+ _, err = suite.grpcClient_2.SetVaultItem(ctx, &pb.SetVaultItemRequest{
+ Item: &pb.VaultItem{
+ Id: itemIDs[3],
+ Name: faker.String(),
+ Itype: pb.IType(vault.Binary),
+ Value: b,
+ },
+ })
+ suite.Require().NoError(err, "gRPC add vault item error", err)
+ })
+
+ suite.Run("sync first client", func() {
+ ctx := newContextWithAuthData(ctx, suite.token)
+
+ respList, err := suite.grpcClient.ListVaultItems(ctx, &pb.ListVaultItemsRequest{
+ Since: lastUpdateFirstClient,
+ })
+ suite.Require().NoError(err, "gRPC list vault items error", err)
+ suite.Assert().Len(respList.Items, 2, "Wrong number of items in response")
+ })
+
+}
+
+func encrypt(plain, secret []byte) []byte {
+ aes, err := aes.NewCipher(secret)
+ if err != nil {
+ panic(err)
+ }
+ gcm, err := cipher.NewGCM(aes)
+ if err != nil {
+ panic(err)
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ _, err = rand.Read(nonce)
+ if err != nil {
+ panic(err)
+ }
+ cipher := gcm.Seal(nonce, nonce, plain, nil)
+ return cipher
+}
diff --git a/test/vault_test.go b/test/vault_test.go
new file mode 100644
index 0000000..b737093
--- /dev/null
+++ b/test/vault_test.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "context"
+ "time"
+
+ "github.com/pioz/faker"
+ "github.com/rs/xid"
+
+ pb "github.com/Karzoug/goph_keeper/common/grpc"
+ "github.com/Karzoug/goph_keeper/common/model/vault"
+)
+
+type VaultSuite struct {
+ commonTestSuite
+}
+
+func (suite *VaultSuite) TestVault() {
+ ctx, cancel := context.WithTimeout(context.Background(), testsTimeout)
+ defer cancel()
+
+ itemId := xid.New().String()
+ var setServerUpdatedAt int64
+
+ suite.Run("add vault item", func() {
+ ctx := newContextWithAuthData(ctx, suite.token)
+
+ now := time.Now().UnixMicro()
+
+ respSet, err := suite.grpcClient.SetVaultItem(ctx, &pb.SetVaultItemRequest{
+ Item: &pb.VaultItem{
+ Id: itemId,
+ Name: faker.String(),
+ Itype: pb.IType(vault.Text),
+ Value: []byte(faker.ArticleWithParagraphCount(faker.IntInRange(0, 10))),
+ },
+ })
+ suite.Require().NoError(err, "gRPC add vault item error", err)
+ setServerUpdatedAt = respSet.ServerUpdatedAt
+ suite.Assert().LessOrEqual(now, setServerUpdatedAt, "returned server update time must be equal or greater than time of request")
+
+ respList, err := suite.grpcClient.ListVaultItems(ctx, &pb.ListVaultItemsRequest{
+ Since: now - 1, // -1 to avoid case server update time equal time of request
+ })
+ suite.Require().NoError(err, "gRPC list vault items error", err)
+ suite.Assert().Len(respList.Items, 1, "returned wrong number of vault items")
+
+ respList, err = suite.grpcClient.ListVaultItems(ctx, &pb.ListVaultItemsRequest{
+ Since: setServerUpdatedAt,
+ })
+ suite.Require().NoError(err, "gRPC list vault items error", err)
+ suite.Assert().Len(respList.Items, 0, "returned wrong number of vault items")
+ })
+
+ suite.Run("update vault item", func() {
+ ctx := newContextWithAuthData(ctx, suite.token)
+
+ updatedName := faker.String()
+
+ respSet, err := suite.grpcClient.SetVaultItem(ctx, &pb.SetVaultItemRequest{
+ Item: &pb.VaultItem{
+ Id: itemId,
+ Name: updatedName,
+ Itype: pb.IType(vault.Text),
+ Value: []byte(faker.ArticleWithParagraphCount(faker.IntInRange(0, 10))),
+ ServerUpdatedAt: setServerUpdatedAt,
+ },
+ })
+ suite.Require().NoError(err, "gRPC update vault item error", err)
+ setServerUpdatedAt = respSet.ServerUpdatedAt
+
+ respList, err := suite.grpcClient.ListVaultItems(ctx, &pb.ListVaultItemsRequest{
+ Since: setServerUpdatedAt - 1,
+ })
+ suite.Require().NoError(err, "gRPC list vault items error", err)
+ suite.Require().Len(respList.Items, 1, "returned wrong number of vault items")
+ suite.Assert().Equal(updatedName, respList.Items[0].Name, "returned wrong vault item name")
+ })
+
+ suite.Run("update vault item with conflict version", func() {
+ ctx := newContextWithAuthData(ctx, suite.token)
+
+ _, err := suite.grpcClient.SetVaultItem(ctx, &pb.SetVaultItemRequest{
+ Item: &pb.VaultItem{
+ Id: itemId,
+ Name: faker.String(),
+ Itype: pb.IType(vault.Text),
+ Value: []byte(faker.ArticleWithParagraphCount(faker.IntInRange(0, 10))),
+ ServerUpdatedAt: setServerUpdatedAt - 10, // client has old version
+ },
+ })
+ suite.Assert().ErrorIs(err, pb.ErrVaultItemConflictVersion)
+ })
+
+ suite.Run("delete vault item", func() {
+ ctx := newContextWithAuthData(ctx, suite.token)
+
+ _, err := suite.grpcClient.SetVaultItem(ctx, &pb.SetVaultItemRequest{
+ Item: &pb.VaultItem{
+ Id: itemId,
+ Name: "",
+ Itype: pb.IType(vault.Text),
+ Value: nil,
+ ServerUpdatedAt: setServerUpdatedAt, // client has old version
+ IsDeleted: true,
+ },
+ })
+ suite.Assert().NoError(err)
+ })
+}