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