Merge branch 'cli-main'

This commit is contained in:
Manav Rathi 2024-03-01 12:39:09 +05:30
commit 40c31cc24e
60 changed files with 5053 additions and 0 deletions

36
cli/.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: Release
on:
# allow manual run
push:
tags:
- 'v*.*.*' # This will run the workflow when you push a new tag in the format v0.0.0
- 'v*.*.*-beta.*'
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Install latest Syft
run: |
wget $(curl -s https://api.github.com/repos/anchore/syft/releases/latest | grep 'browser_' | grep 'linux_amd64.rpm' | cut -d\" -f4) -O syft_latest_linux_amd64.rpm
sudo rpm -i syft_latest_linux_amd64.rpm
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Important to ensure that GoReleaser works correctly
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20' # You can adjust the Go version here
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Use the provided GITHUB_TOKEN secret

13
cli/.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
data/**
.DS_Store
Photos.code-workspace
logs/**
.idea/**
.vscode/**
tmp/**
scratch/**
main
config.yaml
ente-cli.db
bin/**
dist/

58
cli/.goreleaser.yaml Normal file
View file

@ -0,0 +1,58 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
# The lines bellow are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
project_name: ente
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
- go generate ./...
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
nfpms:
- package_name: ente
homepage: https://github.com/ente-io/cli
maintainer: ente.io <engineering@ente.io>
description: |-
Command Line Utility for exporting data from https://ente.io
formats:
- rpm
- deb
- apk
sboms:
- artifacts: archive
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
format: zip
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"

24
cli/Dockerfile Normal file
View file

@ -0,0 +1,24 @@
FROM golang:1.20-alpine3.17 as builder
RUN apk add --no-cache gcc musl-dev git build-base pkgconfig libsodium-dev
ENV GOOS=linux
WORKDIR /etc/ente/
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY . .
# the --mount option requires BuildKit. Refer to https://docs.docker.com/go/buildkit/ to learn how to build images with BuildKit enabled
RUN --mount=type=cache,target=/root/.cache/go-build \
go build -o ente-cli main.go
FROM alpine:3.17
RUN apk add libsodium-dev
COPY --from=builder /etc/ente/ente-cli .
ARG GIT_COMMIT
ENV GIT_COMMIT=$GIT_COMMIT
CMD ["./ente-cli"]

20
cli/Dockerfile-x86 Normal file
View file

@ -0,0 +1,20 @@
FROM golang:1.20-alpine3.17@sha256:9c2f89db6fda13c3c480749787f62fed5831699bb2c32881b8f327f1cf7bae42 as builder386
RUN apt-get update
RUN apt-get install -y gcc
RUN apt-get install -y git
RUN apt-get install -y pkg-config
RUN apt-get install -y libsodium-dev
ENV GOOS=linux
WORKDIR /etc/ente/
RUN uname -a
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
go build -o ente-cli main.go

86
cli/README.md Normal file
View file

@ -0,0 +1,86 @@
# Command Line Utility for exporting data from [Ente](https://ente.io)
## Install
You can either download the binary from the [release page](https://github.com/ente-io/cli/releases) or build it yourself.
### Build from source
```shell
go build -o "bin/ente" main.go
```
### Getting Started
Run the help command to see all available commands.
```shell
ente --help
```
#### Accounts
If you wish, you can add multiple accounts (your own and that of your family members) and export all data using this tool.
##### Add an account
```shell
ente account add
```
##### List accounts
```shell
ente account list
```
##### Change export directory
```shell
ente account update --email email@domain.com --dir ~/photos
```
### Export
##### Start export
```shell
ente export
```
---
## Docker
If you fancy Docker, you can also run the CLI within a container.
### Configure
Modify the `docker-compose.yml` and add volume.
``cli-data`` volume is mandatory, you can add more volumes for your export directory.
Build the docker image
```shell
docker build -t ente:latest .
```
Start the container in detached mode
```bash
docker-compose up -d
```
`exec` into the container
```shell
docker-compose exec ente /bin/sh
```
#### Directly executing commands
```shell
docker run -it --rm ente:latest ls
```
---
## Releases
Run the release script to build the binary and run it.
```shell
./release.sh
```

0
cli/cmd/LICENSE Normal file
View file

84
cli/cmd/account.go Normal file
View file

@ -0,0 +1,84 @@
package cmd
import (
"context"
"fmt"
"github.com/ente-io/cli/internal/api"
"github.com/ente-io/cli/pkg/model"
"github.com/spf13/cobra"
)
// Define the 'account' command and its subcommands
var accountCmd = &cobra.Command{
Use: "account",
Short: "Manage account settings",
}
// Subcommand for 'account list'
var listAccCmd = &cobra.Command{
Use: "list",
Short: "list configured accounts",
RunE: func(cmd *cobra.Command, args []string) error {
recoverWithLog()
return ctrl.ListAccounts(context.Background())
},
}
// Subcommand for 'account add'
var addAccCmd = &cobra.Command{
Use: "add",
Short: "Add a new account",
Run: func(cmd *cobra.Command, args []string) {
recoverWithLog()
ctrl.AddAccount(context.Background())
},
}
// Subcommand for 'account update'
var updateAccCmd = &cobra.Command{
Use: "update",
Short: "Update an existing account's export directory",
Run: func(cmd *cobra.Command, args []string) {
recoverWithLog()
exportDir, _ := cmd.Flags().GetString("dir")
app, _ := cmd.Flags().GetString("app")
email, _ := cmd.Flags().GetString("email")
if email == "" {
fmt.Println("email must be specified")
return
}
if exportDir == "" {
fmt.Println("dir param must be specified")
return
}
validApps := map[string]bool{
"photos": true,
"locker": true,
"auth": true,
}
if !validApps[app] {
fmt.Printf("invalid app. Accepted values are 'photos', 'locker', 'auth'")
}
err := ctrl.UpdateAccount(context.Background(), model.UpdateAccountParams{
Email: email,
App: api.StringToApp(app),
ExportDir: &exportDir,
})
if err != nil {
fmt.Printf("Error updating account: %v\n", err)
}
},
}
func init() {
// Add 'config' subcommands to the root command
rootCmd.AddCommand(accountCmd)
// Add 'config' subcommands to the 'config' command
updateAccCmd.Flags().String("dir", "", "update export directory")
updateAccCmd.Flags().String("email", "", "email address of the account to update")
updateAccCmd.Flags().String("app", "photos", "Specify the app, default is 'photos'")
accountCmd.AddCommand(listAccCmd, addAccCmd, updateAccCmd)
}

57
cli/cmd/config.go Normal file
View file

@ -0,0 +1,57 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// Define the 'config' command and its subcommands
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage configuration settings",
}
// Subcommand for 'config show'
var showCmd = &cobra.Command{
Use: "show",
Short: "Show configuration settings",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("host:", viper.GetString("host"))
},
}
// Subcommand for 'config update'
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update a configuration setting",
Run: func(cmd *cobra.Command, args []string) {
viper.Set("host", host)
err := viper.WriteConfig()
if err != nil {
fmt.Println("Error updating 'host' configuration:", err)
return
}
fmt.Println("Updating 'host' configuration:", host)
},
}
// Flag to specify the 'host' configuration value
var host string
func init() {
// Set up Viper configuration
// Set a default value for 'host' configuration
viper.SetDefault("host", "https://api.ente.io")
// Add 'config' subcommands to the root command
//rootCmd.AddCommand(configCmd)
// Add flags to the 'config store' and 'config update' subcommands
updateCmd.Flags().StringVarP(&host, "host", "H", viper.GetString("host"), "Update the 'host' configuration")
// Mark 'host' flag as required for the 'update' command
updateCmd.MarkFlagRequired("host")
// Add 'config' subcommands to the 'config' command
configCmd.AddCommand(showCmd, updateCmd)
}

19
cli/cmd/export.go Normal file
View file

@ -0,0 +1,19 @@
package cmd
import (
"github.com/spf13/cobra"
)
// versionCmd represents the version command
var exportCmd = &cobra.Command{
Use: "export",
Short: "Starts the export process",
Long: ``,
Run: func(cmd *cobra.Command, args []string) {
ctrl.Export()
},
}
func init() {
rootCmd.AddCommand(exportCmd)
}

63
cli/cmd/root.go Normal file
View file

@ -0,0 +1,63 @@
package cmd
import (
"fmt"
"github.com/ente-io/cli/pkg"
"os"
"runtime"
"github.com/spf13/viper"
"github.com/spf13/cobra"
)
const AppVersion = "0.1.10"
var ctrl *pkg.ClICtrl
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "ente",
Short: "CLI tool for exporting your photos from ente.io",
Long: `Start by creating a config file in your home directory:`,
// Uncomment the following line if your bare application
// has an action associated with it:
Run: func(cmd *cobra.Command, args []string) {
fmt.Sprintf("Hello World")
},
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute(controller *pkg.ClICtrl) {
ctrl = controller
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cli-go.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
viper.SetConfigName("config") // Name of your configuration file (e.g., config.yaml)
viper.AddConfigPath(".") // Search for config file in the current directory
viper.ReadInConfig() // Read the configuration file if it exists
}
func recoverWithLog() {
if r := recover(); r != nil {
fmt.Println("Panic occurred:", r)
// Print the stack trace
stackTrace := make([]byte, 1024*8)
stackTrace = stackTrace[:runtime.Stack(stackTrace, false)]
fmt.Printf("Stack Trace:\n%s", stackTrace)
}
}

21
cli/cmd/version.go Normal file
View file

@ -0,0 +1,21 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// versionCmd represents the version command
var versionCmd = &cobra.Command{
Use: "version",
Short: "Prints the current version",
Long: ``,
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Version %s\n", AppVersion)
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}

0
cli/config.yaml Normal file
View file

11
cli/docker-compose.yml Normal file
View file

@ -0,0 +1,11 @@
version: '3'
services:
ente-cli:
image: ente-cli:latest
command: /bin/sh
volumes:
# Replace /Volumes/Data/ with a folder path on your system, typically $HOME/.ente-cli/
- ~/.ente-cli/:/cli-data:rw
# - ~/Downloads/export-data:/data:rw
stdin_open: true
tty: true

44
cli/go.mod Normal file
View file

@ -0,0 +1,44 @@
module github.com/ente-io/cli
go 1.20
require (
github.com/go-resty/resty/v2 v2.7.0
github.com/google/uuid v1.3.1
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1
github.com/zalando/go-keyring v0.2.3
golang.org/x/crypto v0.14.0
)
require (
github.com/alessio/shellescape v1.4.1 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
)
require (
github.com/fatih/color v1.15.0
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kong/go-srp v0.0.0-20191210190804-cde1efa3c083
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/cobra v1.7.0
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.16.0
github.com/subosito/gotenv v1.6.0 // indirect
go.etcd.io/bbolt v1.3.7
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.13.0
golang.org/x/text v0.13.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

521
cli/go.sum Normal file
View file

@ -0,0 +1,521 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
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/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY=
github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kong/go-srp v0.0.0-20191210190804-cde1efa3c083 h1:Y7nibF/3Ivmk+S4Q+KzVv98lFlSdrBhYzG44d5il85E=
github.com/kong/go-srp v0.0.0-20191210190804-cde1efa3c083/go.mod h1:Zde5RRLiH8/2zEXQDHX5W0dOOTxkemzrXMhHVfxTtTA=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
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=
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1 h1:lYpkrQH5ajf0OXOcUbGjvZxxijuBwbbmlSxLiuofa+g=
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
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/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc=
github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg=
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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
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/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View file

@ -0,0 +1,27 @@
package api
import (
"fmt"
"strings"
)
type ApiError struct {
Message string
StatusCode int
}
func (e *ApiError) Error() string {
return fmt.Sprintf("status %d with err: %s", e.StatusCode, e.Message)
}
func IsApiError(err error) bool {
_, ok := err.(*ApiError)
return ok
}
func IsFileNotInAlbumError(err error) bool {
if apiErr, ok := err.(*ApiError); ok {
return strings.Contains(apiErr.Message, "FILE_NOT_FOUND_IN_ALBUM")
}
return false
}

View file

@ -0,0 +1,97 @@
package api
import (
"context"
"github.com/go-resty/resty/v2"
"log"
"time"
)
const (
EnteAPIEndpoint = "https://api.ente.io"
TokenHeader = "X-Auth-Token"
TokenQuery = "token"
ClientPkgHeader = "X-Client-Package"
)
var (
RedactedHeaders = []string{TokenHeader, " X-Request-Id"}
)
var tokenMap map[string]string = make(map[string]string)
type Client struct {
restClient *resty.Client
// use separate client for downloading files
downloadClient *resty.Client
}
type Params struct {
Debug bool
Trace bool
Host string
}
func readValueFromContext(ctx context.Context, key string) interface{} {
value := ctx.Value(key)
return value
}
func NewClient(p Params) *Client {
enteAPI := resty.New()
if p.Trace {
enteAPI.EnableTrace()
}
enteAPI.OnBeforeRequest(func(c *resty.Client, req *resty.Request) error {
app := readValueFromContext(req.Context(), "app")
if app == nil {
panic("app not set in context")
}
req.Header.Set(ClientPkgHeader, StringToApp(app.(string)).ClientPkg())
attachToken(req)
return nil
})
if p.Debug {
enteAPI.OnBeforeRequest(func(c *resty.Client, req *resty.Request) error {
logRequest(req)
return nil
})
enteAPI.OnAfterResponse(func(c *resty.Client, resp *resty.Response) error {
logResponse(resp)
return nil
})
}
if p.Host != "" {
enteAPI.SetBaseURL(p.Host)
} else {
enteAPI.SetBaseURL(EnteAPIEndpoint)
}
return &Client{
restClient: enteAPI,
downloadClient: resty.New().
SetRetryCount(3).
SetRetryWaitTime(5 * time.Second).
SetRetryMaxWaitTime(10 * time.Second).
AddRetryCondition(func(r *resty.Response, err error) bool {
shouldRetry := r.StatusCode() == 429 || r.StatusCode() > 500
if shouldRetry {
log.Printf("retrying download due to %d code", r.StatusCode())
}
return shouldRetry
}),
}
}
func attachToken(req *resty.Request) {
accountKey := readValueFromContext(req.Context(), "account_key")
if accountKey != nil && accountKey != "" {
if token, ok := tokenMap[accountKey.(string)]; ok {
req.SetHeader(TokenHeader, token)
}
}
}
func (c *Client) AddToken(id string, token string) {
tokenMap[id] = token
}

View file

@ -0,0 +1,64 @@
package api
import (
"context"
"strconv"
)
func (c *Client) GetCollections(ctx context.Context, sinceTime int64) ([]Collection, error) {
var res struct {
Collections []Collection `json:"collections"`
}
r, err := c.restClient.R().
SetContext(ctx).
SetQueryParam("sinceTime", strconv.FormatInt(sinceTime, 10)).
SetResult(&res).
Get("/collections/v2")
if r.IsError() {
return nil, &ApiError{
StatusCode: r.StatusCode(),
Message: r.String(),
}
}
return res.Collections, err
}
func (c *Client) GetFiles(ctx context.Context, collectionID, sinceTime int64) ([]File, bool, error) {
var res struct {
Files []File `json:"diff"`
HasMore bool `json:"hasMore"`
}
r, err := c.restClient.R().
SetContext(ctx).
SetQueryParam("sinceTime", strconv.FormatInt(sinceTime, 10)).
SetQueryParam("collectionID", strconv.FormatInt(collectionID, 10)).
SetResult(&res).
Get("/collections/v2/diff")
if r.IsError() {
return nil, false, &ApiError{
StatusCode: r.StatusCode(),
Message: r.String(),
}
}
return res.Files, res.HasMore, err
}
// GetFile ..
func (c *Client) GetFile(ctx context.Context, collectionID, fileID int64) (*File, error) {
var res struct {
File File `json:"file"`
}
r, err := c.restClient.R().
SetContext(ctx).
SetQueryParam("collectionID", strconv.FormatInt(collectionID, 10)).
SetQueryParam("fileID", strconv.FormatInt(fileID, 10)).
SetResult(&res).
Get("/collections/file")
if r.IsError() {
return nil, &ApiError{
StatusCode: r.StatusCode(),
Message: r.String(),
}
}
return &res.File, err
}

View file

@ -0,0 +1,43 @@
package api
// Collection represents a collection
type Collection struct {
ID int64 `json:"id"`
Owner CollectionUser `json:"owner"`
EncryptedKey string `json:"encryptedKey" binding:"required"`
KeyDecryptionNonce string `json:"keyDecryptionNonce,omitempty" binding:"required"`
Name string `json:"name"`
EncryptedName string `json:"encryptedName"`
NameDecryptionNonce string `json:"nameDecryptionNonce"`
Type string `json:"type" binding:"required"`
Sharees []CollectionUser `json:"sharees"`
UpdationTime int64 `json:"updationTime"`
IsDeleted bool `json:"isDeleted,omitempty"`
MagicMetadata *MagicMetadata `json:"magicMetadata,omitempty"`
PublicMagicMetadata *MagicMetadata `json:"pubMagicMetadata,omitempty"`
SharedMagicMetadata *MagicMetadata `json:"sharedMagicMetadata,omitempty"`
collectionKey []byte
}
// CollectionUser represents the owner of a collection
type CollectionUser struct {
ID int64 `json:"id"`
Email string `json:"email"`
// Deprecated
Name string `json:"name"`
Role string `json:"role"`
}
type MagicMetadata struct {
Version int `json:"version,omitempty" binding:"required"`
Count int `json:"count,omitempty" binding:"required"`
Data string `json:"data,omitempty" binding:"required"`
Header string `json:"header,omitempty" binding:"required"`
}
// CollectionFileItem represents a file in an AddFilesRequest and MoveFilesRequest
type CollectionFileItem struct {
ID int64 `json:"id" binding:"required"`
EncryptedKey string `json:"encryptedKey" binding:"required"`
KeyDecryptionNonce string `json:"keyDecryptionNonce" binding:"required"`
}

35
cli/internal/api/enums.go Normal file
View file

@ -0,0 +1,35 @@
package api
import "fmt"
type App string
const (
AppPhotos App = "photos"
AppAuth App = "auth"
AppLocker App = "locker"
)
func StringToApp(s string) App {
switch s {
case "photos":
return AppPhotos
case "auth":
return AppAuth
case "locker":
return AppLocker
default:
panic(fmt.Sprintf("invalid app: %s", s))
}
}
func (a App) ClientPkg() string {
switch a {
case AppPhotos:
return "io.ente.photos"
case AppAuth:
return "io.ente.auth"
case AppLocker:
return "io.ente.locker"
}
return ""
}

View file

@ -0,0 +1,31 @@
package api
// File represents an encrypted file in the system
type File struct {
ID int64 `json:"id"`
OwnerID int64 `json:"ownerID"`
CollectionID int64 `json:"collectionID"`
CollectionOwnerID *int64 `json:"collectionOwnerID"`
EncryptedKey string `json:"encryptedKey"`
KeyDecryptionNonce string `json:"keyDecryptionNonce"`
File FileAttributes `json:"file" binding:"required"`
Thumbnail FileAttributes `json:"thumbnail" binding:"required"`
Metadata FileAttributes `json:"metadata" binding:"required"`
IsDeleted bool `json:"isDeleted"`
UpdationTime int64 `json:"updationTime"`
MagicMetadata *MagicMetadata `json:"magicMetadata,omitempty"`
PubicMagicMetadata *MagicMetadata `json:"pubMagicMetadata,omitempty"`
Info *FileInfo `json:"info,omitempty"`
}
// FileInfo has information about storage used by the file & it's metadata(future)
type FileInfo struct {
FileSize int64 `json:"fileSize,omitempty"`
ThumbnailSize int64 `json:"thumbSize,omitempty"`
}
// FileAttributes represents a file item
type FileAttributes struct {
EncryptedData string `json:"encryptedData,omitempty"`
DecryptionHeader string `json:"decryptionHeader" binding:"required"`
}

25
cli/internal/api/files.go Normal file
View file

@ -0,0 +1,25 @@
package api
import (
"context"
"strconv"
)
var (
downloadHost = "https://files.ente.io/?fileID="
)
func (c *Client) DownloadFile(ctx context.Context, fileID int64, absolutePath string) error {
req := c.downloadClient.R().
SetContext(ctx).
SetOutput(absolutePath)
attachToken(req)
r, err := req.Get(downloadHost + strconv.FormatInt(fileID, 10))
if r.IsError() {
return &ApiError{
StatusCode: r.StatusCode(),
Message: r.String(),
}
}
return err
}

58
cli/internal/api/log.go Normal file
View file

@ -0,0 +1,58 @@
package api
import (
"fmt"
"strings"
"github.com/fatih/color"
"github.com/go-resty/resty/v2"
)
func logRequest(req *resty.Request) {
fmt.Println(color.GreenString("Request:"))
fmt.Printf("%s %s\n", color.CyanString(req.Method), color.YellowString(req.URL))
fmt.Println(color.GreenString("Headers:"))
for k, v := range req.Header {
redacted := false
for _, rh := range RedactedHeaders {
if strings.EqualFold(strings.ToLower(k), strings.ToLower(rh)) {
redacted = true
break
}
}
if redacted {
fmt.Printf("%s: %s\n", color.CyanString(k), color.RedString("REDACTED"))
} else {
if len(v) == 1 {
fmt.Printf("%s: %s\n", color.CyanString(k), color.YellowString(v[0]))
} else {
fmt.Printf("%s: %s\n", color.CyanString(k), color.YellowString(strings.Join(v, ",")))
}
}
}
}
func logResponse(resp *resty.Response) {
fmt.Println(color.GreenString("Response:"))
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
fmt.Printf("%s %s\n", color.CyanString(resp.Proto()), color.RedString(resp.Status()))
} else {
fmt.Printf("%s %s\n", color.CyanString(resp.Proto()), color.YellowString(resp.Status()))
}
fmt.Printf("Time Duration: %s\n", resp.Time())
fmt.Println(color.GreenString("Headers:"))
for k, v := range resp.Header() {
redacted := false
for _, rh := range RedactedHeaders {
if strings.EqualFold(strings.ToLower(k), strings.ToLower(rh)) {
redacted = true
break
}
}
if redacted {
fmt.Printf("%s: %s\n", color.CyanString(k), color.RedString("REDACTED"))
} else {
fmt.Printf("%s: %s\n", color.CyanString(k), color.YellowString(strings.Join(v, ",")))
}
}
}

163
cli/internal/api/login.go Normal file
View file

@ -0,0 +1,163 @@
package api
import (
"context"
"github.com/google/uuid"
)
func (c *Client) GetSRPAttributes(ctx context.Context, email string) (*SRPAttributes, error) {
var res struct {
SRPAttributes *SRPAttributes `json:"attributes"`
}
r, err := c.restClient.R().
SetContext(ctx).
SetResult(&res).
SetQueryParam("email", email).
Get("/users/srp/attributes")
if err != nil {
return nil, err
}
if r.IsError() {
return nil, &ApiError{
StatusCode: r.StatusCode(),
Message: r.String(),
}
}
return res.SRPAttributes, err
}
func (c *Client) CreateSRPSession(
ctx context.Context,
srpUserID uuid.UUID,
clientPub string,
) (*CreateSRPSessionResponse, error) {
var res CreateSRPSessionResponse
payload := map[string]interface{}{
"srpUserID": srpUserID.String(),
"srpA": clientPub,
}
r, err := c.restClient.R().
SetContext(ctx).
SetResult(&res).
SetBody(payload).
Post("/users/srp/create-session")
if err != nil {
return nil, err
}
if r.IsError() {
return nil, &ApiError{
StatusCode: r.StatusCode(),
Message: r.String(),
}
}
return &res, nil
}
func (c *Client) VerifySRPSession(
ctx context.Context,
srpUserID uuid.UUID,
sessionID uuid.UUID,
clientM1 string,
) (*AuthorizationResponse, error) {
var res AuthorizationResponse
payload := map[string]interface{}{
"srpUserID": srpUserID.String(),
"sessionID": sessionID.String(),
"srpM1": clientM1,
}
r, err := c.restClient.R().
SetContext(ctx).
SetResult(&res).
SetBody(payload).
Post("/users/srp/verify-session")
if err != nil {
return nil, err
}
if r.IsError() {
return nil, &ApiError{
StatusCode: r.StatusCode(),
Message: r.String(),
}
}
return &res, nil
}
func (c *Client) SendEmailOTP(
ctx context.Context,
email string,
) error {
var res AuthorizationResponse
payload := map[string]interface{}{
"email": email,
}
r, err := c.restClient.R().
SetContext(ctx).
SetResult(&res).
SetBody(payload).
Post("/users/ott")
if err != nil {
return err
}
if r.IsError() {
return &ApiError{
StatusCode: r.StatusCode(),
Message: r.String(),
}
}
return nil
}
func (c *Client) VerifyEmail(
ctx context.Context,
email string,
otp string,
) (*AuthorizationResponse, error) {
var res AuthorizationResponse
payload := map[string]interface{}{
"email": email,
"ott": otp,
}
r, err := c.restClient.R().
SetContext(ctx).
SetResult(&res).
SetBody(payload).
Post("/users/verify-email")
if err != nil {
return nil, err
}
if r.IsError() {
return nil, &ApiError{
StatusCode: r.StatusCode(),
Message: r.String(),
}
}
return &res, nil
}
func (c *Client) VerifyTotp(
ctx context.Context,
sessionID string,
otp string,
) (*AuthorizationResponse, error) {
var res AuthorizationResponse
payload := map[string]interface{}{
"sessionID": sessionID,
"code": otp,
}
r, err := c.restClient.R().
SetContext(ctx).
SetResult(&res).
SetBody(payload).
Post("/users/two-factor/verify")
if err != nil {
return nil, err
}
if r.IsError() {
return nil, &ApiError{
StatusCode: r.StatusCode(),
Message: r.String(),
}
}
return &res, nil
}

View file

@ -0,0 +1,47 @@
package api
import (
"github.com/google/uuid"
)
type SRPAttributes struct {
SRPUserID uuid.UUID `json:"srpUserID" binding:"required"`
SRPSalt string `json:"srpSalt" binding:"required"`
MemLimit int `json:"memLimit" binding:"required"`
OpsLimit int `json:"opsLimit" binding:"required"`
KekSalt string `json:"kekSalt" binding:"required"`
IsEmailMFAEnabled bool `json:"isEmailMFAEnabled" binding:"required"`
}
type CreateSRPSessionResponse struct {
SessionID uuid.UUID `json:"sessionID" binding:"required"`
SRPB string `json:"srpB" binding:"required"`
}
// KeyAttributes stores the key related attributes for a user
type KeyAttributes struct {
KEKSalt string `json:"kekSalt" binding:"required"`
KEKHash string `json:"kekHash"`
EncryptedKey string `json:"encryptedKey" binding:"required"`
KeyDecryptionNonce string `json:"keyDecryptionNonce" binding:"required"`
PublicKey string `json:"publicKey" binding:"required"`
EncryptedSecretKey string `json:"encryptedSecretKey" binding:"required"`
SecretKeyDecryptionNonce string `json:"secretKeyDecryptionNonce" binding:"required"`
MemLimit int `json:"memLimit" binding:"required"`
OpsLimit int `json:"opsLimit" binding:"required"`
}
type AuthorizationResponse struct {
ID int64 `json:"id"`
KeyAttributes *KeyAttributes `json:"keyAttributes,omitempty"`
EncryptedToken string `json:"encryptedToken,omitempty"`
Token string `json:"token,omitempty"`
TwoFactorSessionID string `json:"twoFactorSessionID"`
// SrpM2 is sent only if the user is logging via SRP
// SrpM2 is the SRP M2 value aka the proof that the server has the verifier
SrpM2 *string `json:"srpM2,omitempty"`
}
func (a *AuthorizationResponse) IsMFARequired() bool {
return a.TwoFactorSessionID != ""
}

View file

@ -0,0 +1,114 @@
package crypto
import (
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"github.com/minio/blake2b-simd"
"golang.org/x/crypto/argon2"
)
const (
loginSubKeyLen = 32
loginSubKeyId = 1
loginSubKeyContext = "loginctx"
decryptionBufferSize = 4 * 1024 * 1024
)
const (
cryptoKDFBlake2bBytesMin = 16
cryptoKDFBlake2bBytesMax = 64
cryptoGenerichashBlake2bSaltBytes = 16
cryptoGenerichashBlake2bPersonalBytes = 16
BoxSealBytes = 48 // 32 for the ephemeral public key + 16 for the MAC
)
var (
ErrOpenBox = errors.New("failed to open box")
ErrSealedOpenBox = errors.New("failed to open sealed box")
)
const ()
// DeriveArgonKey generates a 32-bit cryptographic key using the Argon2id algorithm.
// Parameters:
// - password: The plaintext password to be hashed.
// - salt: The salt as a base64 encoded string.
// - memLimit: The memory limit in bytes.
// - opsLimit: The number of iterations.
//
// Returns:
// - A byte slice representing the derived key.
// - An error object, which is nil if no error occurs.
func DeriveArgonKey(password, salt string, memLimit, opsLimit int) ([]byte, error) {
if memLimit < 1024 || opsLimit < 1 {
return nil, fmt.Errorf("invalid memory or operation limits")
}
// Decode salt from base64
saltBytes, err := base64.StdEncoding.DecodeString(salt)
if err != nil {
return nil, fmt.Errorf("invalid salt: %v", err)
}
// Generate key using Argon2id
// Note: We're assuming a fixed key length of 32 bytes and changing the threads
key := argon2.IDKey([]byte(password), saltBytes, uint32(opsLimit), uint32(memLimit/1024), 1, 32)
return key, nil
}
// DeriveLoginKey derives a login key from the given key encryption key.
// This loginKey act as user provided password during SRP authentication.
// Parameters: keyEncKey: This is the keyEncryptionKey that is derived from the user's password.
func DeriveLoginKey(keyEncKey []byte) []byte {
subKey, _ := deriveSubKey(keyEncKey, loginSubKeyContext, loginSubKeyId, loginSubKeyLen)
// return the first 16 bytes of the derived key
return subKey[:16]
}
func deriveSubKey(masterKey []byte, context string, subKeyID uint64, subKeyLength uint32) ([]byte, error) {
if subKeyLength < cryptoKDFBlake2bBytesMin || subKeyLength > cryptoKDFBlake2bBytesMax {
return nil, fmt.Errorf("subKeyLength out of bounds")
}
// Pad the context
ctxPadded := make([]byte, cryptoGenerichashBlake2bPersonalBytes)
copy(ctxPadded, []byte(context))
// Convert subKeyID to byte slice and pad
salt := make([]byte, cryptoGenerichashBlake2bSaltBytes)
binary.LittleEndian.PutUint64(salt, subKeyID)
// Create a BLAKE2b configuration
config := &blake2b.Config{
Size: uint8(subKeyLength),
Key: masterKey,
Salt: salt,
Person: ctxPadded,
}
hasher, err := blake2b.New(config)
if err != nil {
return nil, err
}
hasher.Write(nil) // No data, just using key, salt, and personalization
return hasher.Sum(nil), nil
}
func DecryptChaChaBase64(data string, key []byte, nonce string) (string, []byte, error) {
// Decode data from base64
dataBytes, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return "", nil, fmt.Errorf("invalid data: %v", err)
}
// Decode nonce from base64
nonceBytes, err := base64.StdEncoding.DecodeString(nonce)
if err != nil {
return "", nil, fmt.Errorf("invalid nonce: %v", err)
}
// Decrypt data
decryptedData, err := decryptChaCha20poly1305(dataBytes, key, nonceBytes)
if err != nil {
return "", nil, fmt.Errorf("failed to decrypt data: %v", err)
}
return base64.StdEncoding.EncodeToString(decryptedData), decryptedData, nil
}

View file

@ -0,0 +1,259 @@
package crypto
import (
"bufio"
"errors"
"github.com/ente-io/cli/utils/encoding"
"golang.org/x/crypto/nacl/box"
"golang.org/x/crypto/nacl/secretbox"
"io"
"log"
"os"
)
//func EncryptChaCha20poly1305LibSodium(data []byte, key []byte) ([]byte, []byte, error) {
// var buf bytes.Buffer
// encoder := sodium.MakeSecretStreamXCPEncoder(sodium.SecretStreamXCPKey{Bytes: key}, &buf)
// _, err := encoder.WriteAndClose(data)
// if err != nil {
// log.Println("Failed to write to encoder", err)
// return nil, nil, err
// }
// return buf.Bytes(), encoder.Header().Bytes, nil
//}
// EncryptChaCha20poly1305 encrypts the given data using the ChaCha20-Poly1305 algorithm.
// Parameters:
// - data: The plaintext data as a byte slice.
// - key: The key for encryption as a byte slice.
//
// Returns:
// - A byte slice representing the encrypted data.
// - A byte slice representing the header of the encrypted data.
// - An error object, which is nil if no error occurs.
func EncryptChaCha20poly1305(data []byte, key []byte) ([]byte, []byte, error) {
encryptor, header, err := NewEncryptor(key)
if err != nil {
return nil, nil, err
}
encoded, err := encryptor.Push(data, TagFinal)
if err != nil {
return nil, nil, err
}
return encoded, header, nil
}
// decryptChaCha20poly1305 decrypts the given data using the ChaCha20-Poly1305 algorithm.
// Parameters:
// - data: The encrypted data as a byte slice.
// - key: The key for decryption as a byte slice.
// - nonce: The nonce for decryption as a byte slice.
//
// Returns:
// - A byte slice representing the decrypted data.
// - An error object, which is nil if no error occurs.
//func decryptChaCha20poly1305LibSodium(data []byte, key []byte, nonce []byte) ([]byte, error) {
// reader := bytes.NewReader(data)
// header := sodium.SecretStreamXCPHeader{Bytes: nonce}
// decoder, err := sodium.MakeSecretStreamXCPDecoder(
// sodium.SecretStreamXCPKey{Bytes: key},
// reader,
// header)
// if err != nil {
// log.Println("Failed to make secret stream decoder", err)
// return nil, err
// }
// // Buffer to store the decrypted data
// decryptedData := make([]byte, len(data))
// n, err := decoder.Read(decryptedData)
// if err != nil && err != io.EOF {
// log.Println("Failed to read from decoder", err)
// return nil, err
// }
// return decryptedData[:n], nil
//}
func decryptChaCha20poly1305(data []byte, key []byte, nonce []byte) ([]byte, error) {
decryptor, err := NewDecryptor(key, nonce)
if err != nil {
return nil, err
}
decoded, tag, err := decryptor.Pull(data)
if tag != TagFinal {
return nil, errors.New("invalid tag")
}
if err != nil {
return nil, err
}
return decoded, nil
}
//func SecretBoxOpenLibSodium(c []byte, n []byte, k []byte) ([]byte, error) {
// var cp sodium.Bytes = c
// res, err := cp.SecretBoxOpen(sodium.SecretBoxNonce{Bytes: n}, sodium.SecretBoxKey{Bytes: k})
// return res, err
//}
func SecretBoxOpenBase64(cipher string, nonce string, k []byte) ([]byte, error) {
return SecretBoxOpen(encoding.DecodeBase64(cipher), encoding.DecodeBase64(nonce), k)
}
func SecretBoxOpen(c []byte, n []byte, k []byte) ([]byte, error) {
// Check for valid lengths of nonce and key
if len(n) != 24 || len(k) != 32 {
return nil, ErrOpenBox
}
var nonce [24]byte
var key [32]byte
copy(nonce[:], n)
copy(key[:], k)
// Decrypt the message using Go's nacl/secretbox
decrypted, ok := secretbox.Open(nil, c, &nonce, &key)
if !ok {
return nil, ErrOpenBox
}
return decrypted, nil
}
//func SealedBoxOpenLib(cipherText []byte, publicKey, masterSecret []byte) ([]byte, error) {
// var cp sodium.Bytes = cipherText
// om, err := cp.SealedBoxOpen(sodium.BoxKP{
// PublicKey: sodium.BoxPublicKey{Bytes: publicKey},
// SecretKey: sodium.BoxSecretKey{Bytes: masterSecret},
// })
// if err != nil {
// return nil, fmt.Errorf("failed to open sealed box: %v", err)
// }
// return om, nil
//}
func SealedBoxOpen(cipherText, publicKey, masterSecret []byte) ([]byte, error) {
if len(cipherText) < BoxSealBytes {
return nil, ErrOpenBox
}
// Extract ephemeral public key from the ciphertext
var ephemeralPublicKey [32]byte
copy(ephemeralPublicKey[:], publicKey[:32])
// Extract ephemeral public key from the ciphertext
var masterKey [32]byte
copy(masterKey[:], masterSecret[:32])
// Decrypt the message using nacl/box
decrypted, ok := box.OpenAnonymous(nil, cipherText, &ephemeralPublicKey, &masterKey)
if !ok {
return nil, ErrOpenBox
}
return decrypted, nil
}
func DecryptFile(encryptedFilePath string, decryptedFilePath string, key, nonce []byte) error {
inputFile, err := os.Open(encryptedFilePath)
if err != nil {
return err
}
defer inputFile.Close()
outputFile, err := os.Create(decryptedFilePath)
if err != nil {
return err
}
defer outputFile.Close()
reader := bufio.NewReader(inputFile)
writer := bufio.NewWriter(outputFile)
decryptor, err := NewDecryptor(key, nonce)
if err != nil {
return err
}
buf := make([]byte, decryptionBufferSize+XChaCha20Poly1305IetfABYTES)
for {
readCount, err := reader.Read(buf)
if err != nil && err != io.EOF {
log.Println("Failed to read from input file", err)
return err
}
if readCount == 0 {
break
}
n, tag, errErr := decryptor.Pull(buf[:readCount])
if errErr != nil && errErr != io.EOF {
log.Println("Failed to read from decoder", errErr)
return errErr
}
if _, err := writer.Write(n); err != nil {
log.Println("Failed to write to output file", err)
return err
}
if errErr == io.EOF {
break
}
if tag == TagFinal {
break
}
}
if err := writer.Flush(); err != nil {
log.Println("Failed to flush writer", err)
return err
}
return nil
}
//func DecryptFileLib(encryptedFilePath string, decryptedFilePath string, key, nonce []byte) error {
// inputFile, err := os.Open(encryptedFilePath)
// if err != nil {
// return err
// }
// defer inputFile.Close()
//
// outputFile, err := os.Create(decryptedFilePath)
// if err != nil {
// return err
// }
// defer outputFile.Close()
//
// reader := bufio.NewReader(inputFile)
// writer := bufio.NewWriter(outputFile)
//
// header := sodium.SecretStreamXCPHeader{Bytes: nonce}
// decoder, err := sodium.MakeSecretStreamXCPDecoder(
// sodium.SecretStreamXCPKey{Bytes: key},
// reader,
// header)
// if err != nil {
// log.Println("Failed to make secret stream decoder", err)
// return err
// }
//
// buf := make([]byte, decryptionBufferSize)
// for {
// n, errErr := decoder.Read(buf)
// if errErr != nil && errErr != io.EOF {
// log.Println("Failed to read from decoder", errErr)
// return errErr
// }
// if n == 0 {
// break
// }
// if _, err := writer.Write(buf[:n]); err != nil {
// log.Println("Failed to write to output file", err)
// return err
// }
// if errErr == io.EOF {
// break
// }
// }
// if err := writer.Flush(); err != nil {
// log.Println("Failed to flush writer", err)
// return err
// }
// return nil
//}

View file

@ -0,0 +1,88 @@
package crypto
import (
"crypto/rand"
"encoding/base64"
"testing"
)
const (
password = "test_password"
kdfSalt = "vd0dcYMGNLKn/gpT6uTFTw=="
memLimit = 64 * 1024 * 1024 // 64MB
opsLimit = 2
cipherText = "kBXQ2PuX6y/aje5r22H0AehRPh6sQ0ULoeAO"
cipherNonce = "v7wsI+BFZsRMIjDm3rTxPhmi/CaUdkdJ"
expectedPlainText = "plain_text"
expectedDerivedKey = "vp8d8Nee0BbIML4ab8Cp34uYnyrN77cRwTl920flyT0="
)
func TestDeriveArgonKey(t *testing.T) {
derivedKey, err := DeriveArgonKey(password, kdfSalt, memLimit, opsLimit)
if err != nil {
t.Fatalf("Failed to derive key: %v", err)
}
if base64.StdEncoding.EncodeToString(derivedKey) != expectedDerivedKey {
t.Fatalf("Derived key does not match expected key")
}
}
func TestDecryptChaCha20poly1305(t *testing.T) {
derivedKey, err := DeriveArgonKey(password, kdfSalt, memLimit, opsLimit)
if err != nil {
t.Fatalf("Failed to derive key: %v", err)
}
decodedCipherText, err := base64.StdEncoding.DecodeString(cipherText)
if err != nil {
t.Fatalf("Failed to decode cipher text: %v", err)
}
decodedCipherNonce, err := base64.StdEncoding.DecodeString(cipherNonce)
if err != nil {
t.Fatalf("Failed to decode cipher nonce: %v", err)
}
decryptedText, err := decryptChaCha20poly1305(decodedCipherText, derivedKey, decodedCipherNonce)
if err != nil {
t.Fatalf("Failed to decrypt: %v", err)
}
if string(decryptedText) != expectedPlainText {
t.Fatalf("Decrypted text : %s does not match the expected text: %s", string(decryptedText), expectedPlainText)
}
}
func TestEncryptAndDecryptChaCha20Ploy1305(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
if err != nil {
t.Fatalf("Failed to generate random key: %v", err)
}
cipher, nonce, err := EncryptChaCha20poly1305([]byte("plain_text"), key)
if err != nil {
return
}
plainText, err := decryptChaCha20poly1305(cipher, key, nonce)
if err != nil {
t.Fatalf("Failed to decrypt: %v", err)
}
if string(plainText) != "plain_text" {
t.Fatalf("Decrypted text : %s does not match the expected text: %s", string(plainText), "plain_text")
}
}
func TestSecretBoxOpenBase64(t *testing.T) {
sealedCipherText := "KHwRN+RzvTu+jC7mCdkMsqnTPSLvevtZILmcR2OYFbIRPqDyjAl+m8KxD9B5fiEo"
sealNonce := "jgfPDOsQh2VdIHWJVSBicMPF2sQW3HIY"
sealKey, _ := base64.StdEncoding.DecodeString("kercNpvGufMTTHmDwAhz26DgCAvznd1+/buBqKEkWr4=")
expectedSealedText := "O1ObUBMv+SCE1qWHD7+WViEIZcAeTp18Y+m9eMlDE1Y="
plainText, err := SecretBoxOpenBase64(sealedCipherText, sealNonce, sealKey)
if err != nil {
t.Fatalf("Failed to decrypt: %v", err)
}
if expectedSealedText != base64.StdEncoding.EncodeToString(plainText) {
t.Fatalf("Decrypted text : %s does not match the expected text: %s", string(plainText), expectedSealedText)
}
}

View file

@ -0,0 +1,386 @@
package crypto
import (
"bytes"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"golang.org/x/crypto/chacha20"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/poly1305"
)
// public constants
const (
//TagMessage the most common tag, that doesn't add any information about the nature of the message.
TagMessage = 0
// TagPush indicates that the message marks the end of a set of messages,
// but not the end of the stream. For example, a huge JSON string sent as multiple chunks can use this tag to indicate to the application that the string is complete and that it can be decoded. But the stream itself is not closed, and more data may follow.
TagPush = 0x01
// TagRekey "forget" the key used to encrypt this message and the previous ones, and derive a new secret key.
TagRekey = 0x02
// TagFinal indicates that the message marks the end of the stream, and erases the secret key used to encrypt the previous sequence.
TagFinal = TagPush | TagRekey
StreamKeyBytes = chacha20poly1305.KeySize
StreamHeaderBytes = chacha20poly1305.NonceSizeX
// XChaCha20Poly1305IetfABYTES links to crypto_secretstream_xchacha20poly1305_ABYTES
XChaCha20Poly1305IetfABYTES = 16 + 1
)
const cryptoCoreHchacha20InputBytes = 16
/* const crypto_secretstream_xchacha20poly1305_INONCEBYTES = 8 */
const cryptoSecretStreamXchacha20poly1305Counterbytes = 4
var pad0 [16]byte
var invalidKey = errors.New("invalid key")
var invalidInput = errors.New("invalid input")
var cryptoFailure = errors.New("crypto failed")
// crypto_secretstream_xchacha20poly1305_state
type streamState struct {
k [StreamKeyBytes]byte
nonce [chacha20poly1305.NonceSize]byte
pad [8]byte
}
func (s *streamState) reset() {
for i := range s.nonce {
s.nonce[i] = 0
}
s.nonce[0] = 1
}
type Encryptor interface {
Push(m []byte, tag byte) ([]byte, error)
}
type Decryptor interface {
Pull(m []byte) ([]byte, byte, error)
}
type encryptor struct {
streamState
}
type decryptor struct {
streamState
}
func NewStreamKey() []byte {
k := make([]byte, chacha20poly1305.KeySize)
_, _ = rand.Read(k)
return k
}
func NewEncryptor(key []byte) (Encryptor, []byte, error) {
if len(key) != StreamKeyBytes {
return nil, nil, invalidKey
}
header := make([]byte, StreamHeaderBytes)
_, _ = rand.Read(header)
stream := &encryptor{}
k, err := chacha20.HChaCha20(key[:], header[:16])
if err != nil {
//fmt.Printf("error: %v", err)
return nil, nil, err
}
copy(stream.k[:], k)
stream.reset()
for i := range stream.pad {
stream.pad[i] = 0
}
for i, b := range header[cryptoCoreHchacha20InputBytes:] {
stream.nonce[i+cryptoSecretStreamXchacha20poly1305Counterbytes] = b
}
// fmt.Printf("stream: %+v\n", stream.streamState)
return stream, header, nil
}
func (s *encryptor) Push(plain []byte, tag byte) ([]byte, error) {
var err error
//crypto_onetimeauth_poly1305_state poly1305_state;
var poly *poly1305.MAC
//unsigned char block[64U];
var block [64]byte
//unsigned char slen[8U];
var slen [8]byte
//unsigned char *c;
//unsigned char *mac;
//
//if (outlen_p != NULL) {
//*outlen_p = 0U;
//}
mlen := len(plain)
//if (mlen > crypto_secretstream_xchacha20poly1305_MESSAGEBYTES_MAX) {
//sodium_misuse();
//}
out := make([]byte, mlen+XChaCha20Poly1305IetfABYTES)
chacha, err := chacha20.NewUnauthenticatedCipher(s.k[:], s.nonce[:])
if err != nil {
return nil, err
}
//crypto_stream_chacha20_ietf(block, sizeof block, state->nonce, state->k);
chacha.XORKeyStream(block[:], block[:])
//crypto_onetimeauth_poly1305_init(&poly1305_state, block);
var poly_init [32]byte
copy(poly_init[:], block[:])
poly = poly1305.New(&poly_init)
// TODO add support for add data
//sodium_memzero(block, sizeof block);
//crypto_onetimeauth_poly1305_update(&poly1305_state, ad, adlen);
//crypto_onetimeauth_poly1305_update(&poly1305_state, _pad0,
//(0x10 - adlen) & 0xf);
//memset(block, 0, sizeof block);
//block[0] = tag;
memZero(block[:])
block[0] = tag
//
//crypto_stream_chacha20_ietf_xor_ic(block, block, sizeof block, state->nonce, 1U, state->k);
//crypto_onetimeauth_poly1305_update(&poly1305_state, block, sizeof block);
//out[0] = block[0];
chacha.XORKeyStream(block[:], block[:])
_, _ = poly.Write(block[:])
out[0] = block[0]
//
//c = out + (sizeof tag);
c := out[1:]
//crypto_stream_chacha20_ietf_xor_ic(c, m, mlen, state->nonce, 2U, state->k);
//crypto_onetimeauth_poly1305_update(&poly1305_state, c, mlen);
//crypto_onetimeauth_poly1305_update (&poly1305_state, _pad0, (0x10 - (sizeof block) + mlen) & 0xf);
chacha.XORKeyStream(c, plain)
_, _ = poly.Write(c[:mlen])
padlen := (0x10 - len(block) + mlen) & 0xf
_, _ = poly.Write(pad0[:padlen])
//
//STORE64_LE(slen, (uint64_t) adlen);
//crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
binary.LittleEndian.PutUint64(slen[:], uint64(0))
_, _ = poly.Write(slen[:])
//STORE64_LE(slen, (sizeof block) + mlen);
//crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
binary.LittleEndian.PutUint64(slen[:], uint64(len(block)+mlen))
_, _ = poly.Write(slen[:])
//
//mac = c + mlen;
//crypto_onetimeauth_poly1305_final(&poly1305_state, mac);
mac := c[mlen:]
copy(mac, poly.Sum(nil))
//sodium_memzero(&poly1305_state, sizeof poly1305_state);
//
//XOR_BUF(STATE_INONCE(state), mac, crypto_secretstream_xchacha20poly1305_INONCEBYTES);
//sodium_increment(STATE_COUNTER(state), crypto_secretstream_xchacha20poly1305_COUNTERBYTES);
xorBuf(s.nonce[cryptoSecretStreamXchacha20poly1305Counterbytes:], mac)
bufInc(s.nonce[:cryptoSecretStreamXchacha20poly1305Counterbytes])
// TODO
//if ((tag & crypto_secretstream_xchacha20poly1305_TAG_REKEY) != 0 ||
//sodium_is_zero(STATE_COUNTER(state),
//crypto_secretstream_xchacha20poly1305_COUNTERBYTES)) {
//crypto_secretstream_xchacha20poly1305_rekey(state);
//}
//if (outlen_p != NULL) {
//*outlen_p = crypto_secretstream_xchacha20poly1305_ABYTES + mlen;
//}
//return 0;
return out, nil
}
func NewDecryptor(key, header []byte) (Decryptor, error) {
stream := &decryptor{}
//crypto_core_hchacha20(state->k, in, k, NULL);
k, err := chacha20.HChaCha20(key, header[:16])
if err != nil {
fmt.Printf("error: %v", err)
return nil, err
}
copy(stream.k[:], k)
//_crypto_secretstream_xchacha20poly1305_counter_reset(state);
stream.reset()
//memcpy(STATE_INONCE(state), in + crypto_core_hchacha20_INPUTBYTES,
// crypto_secretstream_xchacha20poly1305_INONCEBYTES);
copy(stream.nonce[cryptoSecretStreamXchacha20poly1305Counterbytes:],
header[cryptoCoreHchacha20InputBytes:])
//memset(state->_pad, 0, sizeof state->_pad);
copy(stream.pad[:], pad0[:])
//fmt.Printf("decryptor: %+v\n", stream.streamState)
return stream, nil
}
func (s *decryptor) Pull(cipher []byte) ([]byte, byte, error) {
cipherLen := len(cipher)
//crypto_onetimeauth_poly1305_state poly1305_state;
var poly1305State [32]byte
//unsigned char block[64U];
var block [64]byte
//unsigned char slen[8U];
var slen [8]byte
//unsigned char mac[crypto_onetimeauth_poly1305_BYTES];
//const unsigned char *c;
//const unsigned char *stored_mac;
//unsigned long long mlen; // length of the returned message
//unsigned char tag; // for the return value
//
//if (mlen_p != NULL) {
//*mlen_p = 0U;
//}
//if (tag_p != NULL) {
//*tag_p = 0xff;
//}
/*
if (inlen < crypto_secretstream_xchacha20poly1305_ABYTES) {
return -1;
}
mlen = inlen - crypto_secretstream_xchacha20poly1305_ABYTES;
*/
if cipherLen < XChaCha20Poly1305IetfABYTES {
return nil, 0, invalidInput
}
mlen := cipherLen - XChaCha20Poly1305IetfABYTES
//if (mlen > crypto_secretstream_xchacha20poly1305_MESSAGEBYTES_MAX) {
//sodium_misuse();
//}
//crypto_stream_chacha20_ietf(block, sizeof block, state->nonce, state->k);
chacha, err := chacha20.NewUnauthenticatedCipher(s.k[:], s.nonce[:])
if err != nil {
return nil, 0, err
}
chacha.XORKeyStream(block[:], block[:])
//crypto_onetimeauth_poly1305_init(&poly1305_state, block);
copy(poly1305State[:], block[:])
poly := poly1305.New(&poly1305State)
// TODO
//sodium_memzero(block, sizeof block);
//crypto_onetimeauth_poly1305_update(&poly1305_state, ad, adlen);
//crypto_onetimeauth_poly1305_update(&poly1305_state, _pad0,
//(0x10 - adlen) & 0xf);
//
//memset(block, 0, sizeof block);
//block[0] = in[0];
//crypto_stream_chacha20_ietf_xor_ic(block, block, sizeof block, state->nonce, 1U, state->k);
memZero(block[:])
block[0] = cipher[0]
chacha.XORKeyStream(block[:], block[:])
//tag = block[0];
//block[0] = in[0];
//crypto_onetimeauth_poly1305_update(&poly1305_state, block, sizeof block);
tag := block[0]
block[0] = cipher[0]
if _, err = poly.Write(block[:]); err != nil {
return nil, 0, err
}
//c = in + (sizeof tag);
//crypto_onetimeauth_poly1305_update(&poly1305_state, c, mlen);
//crypto_onetimeauth_poly1305_update (&poly1305_state, _pad0, (0x10 - (sizeof block) + mlen) & 0xf);
c := cipher[1:]
if _, err = poly.Write(c[:mlen]); err != nil {
return nil, 0, err
}
padLen := (0x10 - len(block) + mlen) & 0xf
if _, err = poly.Write(pad0[:padLen]); err != nil {
return nil, 0, err
}
//
//STORE64_LE(slen, (uint64_t) adlen);
//crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
binary.LittleEndian.PutUint64(slen[:], uint64(0))
if _, err = poly.Write(slen[:]); err != nil {
return nil, 0, err
}
//STORE64_LE(slen, (sizeof block) + mlen);
//crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen);
binary.LittleEndian.PutUint64(slen[:], uint64(len(block)+mlen))
if _, err = poly.Write(slen[:]); err != nil {
return nil, 0, err
}
//
//crypto_onetimeauth_poly1305_final(&poly1305_state, mac);
//sodium_memzero(&poly1305_state, sizeof poly1305_state);
mac := poly.Sum(nil)
memZero(poly1305State[:])
//stored_mac = c + mlen;
//if (sodium_memcmp(mac, stored_mac, sizeof mac) != 0) {
//sodium_memzero(mac, sizeof mac);
//return -1;
//}
storedMac := c[mlen:]
if !bytes.Equal(mac, storedMac) {
memZero(mac)
return nil, 0, cryptoFailure
}
//crypto_stream_chacha20_ietf_xor_ic(m, c, mlen, state->nonce, 2U, state->k);
//XOR_BUF(STATE_INONCE(state), mac, crypto_secretstream_xchacha20poly1305_INONCEBYTES);
//sodium_increment(STATE_COUNTER(state), crypto_secretstream_xchacha20poly1305_COUNTERBYTES);
m := make([]byte, mlen)
chacha.XORKeyStream(m, c[:mlen])
xorBuf(s.nonce[cryptoSecretStreamXchacha20poly1305Counterbytes:], mac)
bufInc(s.nonce[:cryptoSecretStreamXchacha20poly1305Counterbytes])
// TODO
//if ((tag & crypto_secretstream_xchacha20poly1305_TAG_REKEY) != 0 ||
//sodium_is_zero(STATE_COUNTER(state),
//crypto_secretstream_xchacha20poly1305_COUNTERBYTES)) {
//crypto_secretstream_xchacha20poly1305_rekey(state);
//}
//if (mlen_p != NULL) {
//*mlen_p = mlen;
//}
//if (tag_p != NULL) {
//*tag_p = tag;
//}
//return 0;
return m, tag, nil
}

View file

@ -0,0 +1,23 @@
package crypto
func memZero(b []byte) {
for i := range b {
b[i] = 0
}
}
func xorBuf(out, in []byte) {
for i := range out {
out[i] ^= in[i]
}
}
func bufInc(n []byte) {
c := 1
for i := range n {
c += int(n[i])
n[i] = byte(c)
c >>= 8
}
}

150
cli/internal/promt.go Normal file
View file

@ -0,0 +1,150 @@
package internal
import (
"bufio"
"errors"
"fmt"
"github.com/ente-io/cli/internal/api"
"log"
"os"
"strings"
"golang.org/x/term"
)
func GetSensitiveField(label string) (string, error) {
fmt.Printf("%s: ", label)
input, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
return "", err
}
return string(input), nil
}
func GetUserInput(label string) (string, error) {
fmt.Printf("%s: ", label)
var input string
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
//_, err := fmt.Scanln(&input)
if err != nil {
return "", err
}
input = strings.TrimSpace(input)
if input == "" {
return "", errors.New("input cannot be empty")
}
return input, nil
}
func GetAppType() api.App {
for {
app, err := GetUserInput("Enter app type (default: photos)")
if err != nil {
fmt.Printf("Use default app type: %s\n", api.AppPhotos)
return api.AppPhotos
}
switch app {
case "photos":
return api.AppPhotos
case "auth":
return api.AppAuth
case "locker":
return api.AppLocker
case "":
return api.AppPhotos
default:
fmt.Println("invalid app type")
continue
}
}
}
func GetCode(promptText string, length int) (string, error) {
for {
ott, err := GetUserInput(promptText)
if err != nil {
return "", err
}
if ott == "" {
log.Fatal("no OTP entered")
return "", errors.New("no OTP entered")
}
if ott == "c" {
return "", errors.New("OTP entry cancelled")
}
if len(ott) != length {
fmt.Printf("OTP must be %d digits", length)
continue
}
return ott, nil
}
}
func GetExportDir() string {
for {
exportDir, err := GetUserInput("Enter export directory")
if err != nil {
log.Printf("invalid export directory input: %s\n", err)
return ""
}
if exportDir == "" {
log.Printf("invalid export directory: %s\n", err)
continue
}
exportDir, err = ResolvePath(exportDir)
if err != nil {
log.Printf("invalid export directory: %s\n", err)
continue
}
_, err = ValidateDirForWrite(exportDir)
if err != nil {
log.Printf("invalid export directory: %s\n", err)
continue
}
return exportDir
}
}
func ValidateDirForWrite(dir string) (bool, error) {
// Check if the path exists
fileInfo, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
return false, fmt.Errorf("path does not exist: %s", dir)
}
return false, err
}
// Check if the path is a directory
if !fileInfo.IsDir() {
return false, fmt.Errorf("path is not a directory")
}
// Check for write permission
// Check for write permission by creating a temp file
tempFile, err := os.CreateTemp(dir, "write_test_")
if err != nil {
return false, fmt.Errorf("write permission denied: %v", err)
}
// Delete temp file
defer os.Remove(tempFile.Name())
if err != nil {
return false, err
}
return true, nil
}
func ResolvePath(path string) (string, error) {
if path[:2] != "~/" {
return path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return home + path[1:], nil
}

91
cli/main.go Normal file
View file

@ -0,0 +1,91 @@
package main
import (
"fmt"
"github.com/ente-io/cli/cmd"
"github.com/ente-io/cli/internal"
"github.com/ente-io/cli/internal/api"
"github.com/ente-io/cli/pkg"
"github.com/ente-io/cli/pkg/secrets"
"github.com/ente-io/cli/utils/constants"
"log"
"os"
"path/filepath"
"strings"
)
func main() {
cliDBPath, err := GetCLIConfigPath()
if secrets.IsRunningInContainer() {
cliDBPath = constants.CliDataPath
_, err := internal.ValidateDirForWrite(cliDBPath)
if err != nil {
log.Fatalf("Please mount a volume to %s to persist cli data\n%v\n", cliDBPath, err)
}
}
if err != nil {
log.Fatalf("Could not create cli config path\n%v\n", err)
}
newCliPath := fmt.Sprintf("%s/ente-cli.db", cliDBPath)
if !strings.HasPrefix(cliDBPath, "/") {
oldCliPath := fmt.Sprintf("%sente-cli.db", cliDBPath)
if _, err := os.Stat(oldCliPath); err == nil {
log.Printf("migrating old cli db from %s to %s\n", oldCliPath, newCliPath)
if err := os.Rename(oldCliPath, newCliPath); err != nil {
log.Fatalf("Could not rename old cli db\n%v\n", err)
}
}
}
db, err := pkg.GetDB(newCliPath)
if err != nil {
if strings.Contains(err.Error(), "timeout") {
log.Fatalf("Please close all other instances of the cli and try again\n%v\n", err)
} else {
panic(err)
}
}
ctrl := pkg.ClICtrl{
Client: api.NewClient(api.Params{
Debug: false,
//Host: "http://localhost:8080",
}),
DB: db,
KeyHolder: secrets.NewKeyHolder(secrets.GetOrCreateClISecret()),
}
err = ctrl.Init()
if err != nil {
panic(err)
}
defer func() {
if err := db.Close(); err != nil {
panic(err)
}
}()
cmd.Execute(&ctrl)
}
// GetCLIConfigPath returns the path to the .ente-cli folder and creates it if it doesn't exist.
func GetCLIConfigPath() (string, error) {
if os.Getenv("ENTE_CLI_CONFIG_PATH") != "" {
return os.Getenv("ENTE_CLI_CONFIG_PATH"), nil
}
// Get the user's home directory
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
cliDBPath := filepath.Join(homeDir, ".ente")
// Check if the folder already exists, if not, create it
if _, err := os.Stat(cliDBPath); os.IsNotExist(err) {
err := os.MkdirAll(cliDBPath, 0755)
if err != nil {
return "", err
}
}
return cliDBPath, nil
}

181
cli/pkg/account.go Normal file
View file

@ -0,0 +1,181 @@
package pkg
import (
"context"
"encoding/json"
"fmt"
"github.com/ente-io/cli/internal"
"github.com/ente-io/cli/internal/api"
"github.com/ente-io/cli/pkg/model"
"github.com/ente-io/cli/utils/encoding"
"log"
bolt "go.etcd.io/bbolt"
)
const AccBucket = "accounts"
func (c *ClICtrl) AddAccount(cxt context.Context) {
var flowErr error
defer func() {
if flowErr != nil {
log.Fatal(flowErr)
}
}()
app := internal.GetAppType()
cxt = context.WithValue(cxt, "app", string(app))
dir := internal.GetExportDir()
if dir == "" {
flowErr = fmt.Errorf("export directory not set")
return
}
email, flowErr := internal.GetUserInput("Enter email address")
if flowErr != nil {
return
}
var verifyEmail bool
srpAttr, flowErr := c.Client.GetSRPAttributes(cxt, email)
if flowErr != nil {
// if flowErr type is ApiError and status code is 404, then set verifyEmail to true and continue
// else return
if apiErr, ok := flowErr.(*api.ApiError); ok && apiErr.StatusCode == 404 {
verifyEmail = true
} else {
return
}
}
var authResponse *api.AuthorizationResponse
var keyEncKey []byte
if verifyEmail || srpAttr.IsEmailMFAEnabled {
authResponse, flowErr = c.validateEmail(cxt, email)
} else {
authResponse, keyEncKey, flowErr = c.signInViaPassword(cxt, srpAttr)
}
if flowErr != nil {
return
}
if authResponse.IsMFARequired() {
authResponse, flowErr = c.validateTOTP(cxt, authResponse)
}
if authResponse.EncryptedToken == "" || authResponse.KeyAttributes == nil {
panic("no encrypted token or keyAttributes")
}
secretInfo, decErr := c.decryptAccSecretInfo(cxt, authResponse, keyEncKey)
if decErr != nil {
flowErr = decErr
return
}
err := c.storeAccount(cxt, email, authResponse.ID, app, secretInfo, dir)
if err != nil {
flowErr = err
return
} else {
fmt.Println("Account added successfully")
fmt.Println("run `ente export` to initiate export of your account data")
}
}
func (c *ClICtrl) storeAccount(_ context.Context, email string, userID int64, app api.App, secretInfo *model.AccSecretInfo, exportDir string) error {
// get password
err := c.DB.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(AccBucket))
if err != nil {
return err
}
accInfo := model.Account{
Email: email,
UserID: userID,
MasterKey: *model.MakeEncString(secretInfo.MasterKey, c.KeyHolder.DeviceKey),
SecretKey: *model.MakeEncString(secretInfo.SecretKey, c.KeyHolder.DeviceKey),
Token: *model.MakeEncString(secretInfo.Token, c.KeyHolder.DeviceKey),
App: app,
PublicKey: encoding.EncodeBase64(secretInfo.PublicKey),
ExportDir: exportDir,
}
accInfoBytes, err := json.Marshal(accInfo)
if err != nil {
return err
}
accountKey := accInfo.AccountKey()
return b.Put([]byte(accountKey), accInfoBytes)
})
return err
}
func (c *ClICtrl) GetAccounts(cxt context.Context) ([]model.Account, error) {
var accounts []model.Account
err := c.DB.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(AccBucket))
err := b.ForEach(func(k, v []byte) error {
var info model.Account
err := json.Unmarshal(v, &info)
if err != nil {
return err
}
accounts = append(accounts, info)
return nil
})
if err != nil {
return err
}
return nil
})
return accounts, err
}
func (c *ClICtrl) ListAccounts(cxt context.Context) error {
accounts, err := c.GetAccounts(cxt)
if err != nil {
return err
}
fmt.Printf("Configured accounts: %d\n", len(accounts))
for _, acc := range accounts {
fmt.Println("====================================")
fmt.Println("Email: ", acc.Email)
fmt.Println("ID: ", acc.UserID)
fmt.Println("App: ", acc.App)
fmt.Println("ExportDir:", acc.ExportDir)
fmt.Println("====================================")
}
return nil
}
func (c *ClICtrl) UpdateAccount(ctx context.Context, params model.UpdateAccountParams) error {
accounts, err := c.GetAccounts(ctx)
if err != nil {
return err
}
var acc *model.Account
for _, a := range accounts {
if a.Email == params.Email && a.App == params.App {
acc = &a
break
}
}
if acc == nil {
return fmt.Errorf("account not found, use `account list` to list accounts")
}
if params.ExportDir != nil && *params.ExportDir != "" {
_, err := internal.ValidateDirForWrite(*params.ExportDir)
if err != nil {
return err
}
acc.ExportDir = *params.ExportDir
}
err = c.DB.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(AccBucket))
if err != nil {
return err
}
accInfoBytes, err := json.Marshal(acc)
if err != nil {
return err
}
accountKey := acc.AccountKey()
return b.Put([]byte(accountKey), accInfoBytes)
})
return err
}

20
cli/pkg/bolt_store.go Normal file
View file

@ -0,0 +1,20 @@
package pkg
import (
"context"
"fmt"
"github.com/ente-io/cli/pkg/model"
"github.com/ente-io/cli/utils/encoding"
)
func boltAEKey(entry *model.AlbumFileEntry) []byte {
return []byte(fmt.Sprintf("%d:%d", entry.AlbumID, entry.FileID))
}
func (c *ClICtrl) DeleteAlbumEntry(ctx context.Context, entry *model.AlbumFileEntry) error {
return c.DeleteValue(ctx, model.RemoteAlbumEntries, boltAEKey(entry))
}
func (c *ClICtrl) UpsertAlbumEntry(ctx context.Context, entry *model.AlbumFileEntry) error {
return c.PutValue(ctx, model.RemoteAlbumEntries, boltAEKey(entry), encoding.MustMarshalJSON(entry))
}

36
cli/pkg/cli.go Normal file
View file

@ -0,0 +1,36 @@
package pkg
import (
"fmt"
"github.com/ente-io/cli/internal/api"
"github.com/ente-io/cli/pkg/secrets"
bolt "go.etcd.io/bbolt"
"os"
"path/filepath"
)
type ClICtrl struct {
Client *api.Client
DB *bolt.DB
KeyHolder *secrets.KeyHolder
tempFolder string
}
func (c *ClICtrl) Init() error {
tempPath := filepath.Join(os.TempDir(), "ente-download")
// create temp folder if not exists
if _, err := os.Stat(tempPath); os.IsNotExist(err) {
err = os.Mkdir(tempPath, 0755)
if err != nil {
return err
}
}
c.tempFolder = tempPath
return c.DB.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(AccBucket))
if err != nil {
return fmt.Errorf("create bucket: %s", err)
}
return nil
})
}

176
cli/pkg/disk.go Normal file
View file

@ -0,0 +1,176 @@
package pkg
import (
"encoding/json"
"errors"
"fmt"
"github.com/ente-io/cli/pkg/model"
"github.com/ente-io/cli/pkg/model/export"
"io"
"os"
"strings"
)
const (
albumMetaFile = "album_meta.json"
albumMetaFolder = ".meta"
)
type albumDiskInfo struct {
ExportRoot string
AlbumMeta *export.AlbumMetadata
// FileNames contain the name of the files at root level of the album folder
FileNames *map[string]bool
MetaFileNameToDiskFileMap *map[string]*export.DiskFileMetadata
FileIdToDiskFileMap *map[int64]*export.DiskFileMetadata
}
func (a *albumDiskInfo) IsFilePresent(file model.RemoteFile) bool {
// check if file.ID is present
_, ok := (*a.FileIdToDiskFileMap)[file.ID]
return ok
}
func (a *albumDiskInfo) IsFileNamePresent(fileName string) bool {
_, ok := (*a.FileNames)[strings.ToLower(fileName)]
return ok
}
func (a *albumDiskInfo) AddEntry(metadata *export.DiskFileMetadata) error {
if _, ok := (*a.FileIdToDiskFileMap)[metadata.Info.ID]; ok {
return errors.New("fileID already present")
}
if _, ok := (*a.MetaFileNameToDiskFileMap)[strings.ToLower(metadata.MetaFileName)]; ok {
return errors.New("fileName already present")
}
(*a.MetaFileNameToDiskFileMap)[strings.ToLower(metadata.MetaFileName)] = metadata
(*a.FileIdToDiskFileMap)[metadata.Info.ID] = metadata
for _, filename := range metadata.Info.FileNames {
if _, ok := (*a.FileNames)[strings.ToLower(filename)]; ok {
return errors.New("fileName already present")
}
(*a.FileNames)[strings.ToLower(filename)] = true
}
return nil
}
func (a *albumDiskInfo) RemoveEntry(metadata *export.DiskFileMetadata) error {
if _, ok := (*a.FileIdToDiskFileMap)[metadata.Info.ID]; !ok {
return errors.New("fileID not present")
}
if _, ok := (*a.MetaFileNameToDiskFileMap)[strings.ToLower(metadata.MetaFileName)]; !ok {
return errors.New("fileName not present")
}
delete(*a.MetaFileNameToDiskFileMap, strings.ToLower(metadata.MetaFileName))
delete(*a.FileIdToDiskFileMap, metadata.Info.ID)
for _, filename := range metadata.Info.FileNames {
delete(*a.FileNames, strings.ToLower(filename))
}
return nil
}
func (a *albumDiskInfo) IsMetaFileNamePresent(metaFileName string) bool {
_, ok := (*a.MetaFileNameToDiskFileMap)[strings.ToLower(metaFileName)]
return ok
}
// GenerateUniqueMetaFileName generates a unique metafile name.
func (a *albumDiskInfo) GenerateUniqueMetaFileName(baseFileName, extension string) string {
potentialDiskFileName := fmt.Sprintf("%s%s.json", baseFileName, extension)
count := 1
for a.IsMetaFileNamePresent(potentialDiskFileName) {
// separate the file name and extension
fileName := fmt.Sprintf("%s_%d", baseFileName, count)
potentialDiskFileName = fmt.Sprintf("%s%s.json", fileName, extension)
count++
if !a.IsMetaFileNamePresent(potentialDiskFileName) {
break
}
}
return potentialDiskFileName
}
// GenerateUniqueFileName generates a unique file name.
func (a *albumDiskInfo) GenerateUniqueFileName(baseFileName, extension string) string {
fileName := fmt.Sprintf("%s%s", baseFileName, extension)
count := 1
for a.IsFileNamePresent(strings.ToLower(fileName)) {
// separate the file name and extension
fileName = fmt.Sprintf("%s_%d%s", baseFileName, count, extension)
count++
if !a.IsFileNamePresent(strings.ToLower(fileName)) {
break
}
}
return fileName
}
func (a *albumDiskInfo) GetDiskFileMetadata(file model.RemoteFile) *export.DiskFileMetadata {
// check if file.ID is present
diskFile, ok := (*a.FileIdToDiskFileMap)[file.ID]
if !ok {
return nil
}
return diskFile
}
func writeJSONToFile(filePath string, data interface{}) error {
file, err := os.Create(filePath)
if err != nil {
return err
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
return encoder.Encode(data)
}
func readJSONFromFile(filePath string, data interface{}) error {
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
decoder := json.NewDecoder(file)
return decoder.Decode(data)
}
func Move(source, destination string) error {
err := os.Rename(source, destination)
if err != nil {
return moveCrossDevice(source, destination)
}
return err
}
func moveCrossDevice(source, destination string) error {
src, err := os.Open(source)
if err != nil {
return err
}
dst, err := os.Create(destination)
if err != nil {
src.Close()
return err
}
_, err = io.Copy(dst, src)
src.Close()
dst.Close()
if err != nil {
return err
}
fi, err := os.Stat(source)
if err != nil {
os.Remove(destination)
return err
}
err = os.Chmod(destination, fi.Mode())
if err != nil {
os.Remove(destination)
return err
}
os.Remove(source)
return nil
}

32
cli/pkg/disk_test.go Normal file
View file

@ -0,0 +1,32 @@
package pkg
import (
"path/filepath"
"strings"
"testing"
)
func TestGenerateUniqueFileName(t *testing.T) {
existingFilenames := make(map[string]bool)
testFilename := "FullSizeRender.jpg" // what Apple calls shared files
existingFilenames[strings.ToLower(testFilename)] = true
a := &albumDiskInfo{
FileNames: &existingFilenames,
}
// this is taken from downloadEntry()
extension := filepath.Ext(testFilename)
baseFileName := strings.TrimSuffix(filepath.Clean(filepath.Base(testFilename)), extension)
for i := 0; i < 100; i++ {
newFilename := a.GenerateUniqueFileName(baseFileName, extension)
if strings.Contains(newFilename, "_1_2") {
t.Fatalf("Filename contained _1_2")
} else {
// add generated name to existing files
existingFilenames[strings.ToLower(newFilename)] = true
}
}
}

97
cli/pkg/download.go Normal file
View file

@ -0,0 +1,97 @@
package pkg
import (
"archive/zip"
"context"
"fmt"
"github.com/ente-io/cli/internal/crypto"
"github.com/ente-io/cli/pkg/model"
"github.com/ente-io/cli/utils"
"github.com/ente-io/cli/utils/encoding"
"io"
"log"
"os"
"path/filepath"
"strings"
)
func (c *ClICtrl) downloadAndDecrypt(
ctx context.Context,
file model.RemoteFile,
deviceKey []byte,
) (*string, error) {
dir := c.tempFolder
downloadPath := fmt.Sprintf("%s/%d", dir, file.ID)
// check if file exists
if stat, err := os.Stat(downloadPath); err == nil && stat.Size() == file.Info.FileSize {
log.Printf("File already exists %s (%s)", file.GetTitle(), utils.ByteCountDecimal(file.Info.FileSize))
} else {
log.Printf("Downloading %s (%s)", file.GetTitle(), utils.ByteCountDecimal(file.Info.FileSize))
err := c.Client.DownloadFile(ctx, file.ID, downloadPath)
if err != nil {
return nil, fmt.Errorf("error downloading file %d: %w", file.ID, err)
}
}
decryptedPath := fmt.Sprintf("%s/%d.decrypted", dir, file.ID)
err := crypto.DecryptFile(downloadPath, decryptedPath, file.Key.MustDecrypt(deviceKey), encoding.DecodeBase64(file.FileNonce))
if err != nil {
log.Printf("Error decrypting file %d: %s", file.ID, err)
return nil, model.ErrDecryption
} else {
_ = os.Remove(downloadPath)
}
return &decryptedPath, nil
}
func UnpackLive(src string) (imagePath, videoPath string, retErr error) {
var filenames []string
reader, err := zip.OpenReader(src)
if err != nil {
retErr = err
return
}
defer reader.Close()
dest := filepath.Dir(src)
for _, file := range reader.File {
destFilePath := filepath.Join(dest, file.Name)
filenames = append(filenames, destFilePath)
destDir := filepath.Dir(destFilePath)
if err := os.MkdirAll(destDir, 0755); err != nil {
retErr = err
return
}
destFile, err := os.Create(destFilePath)
if err != nil {
retErr = err
return
}
defer destFile.Close()
srcFile, err := file.Open()
if err != nil {
retErr = err
return
}
defer srcFile.Close()
_, err = io.Copy(destFile, srcFile)
if err != nil {
retErr = err
return
}
}
for _, filepath := range filenames {
if strings.Contains(strings.ToLower(filepath), "image") {
imagePath = filepath
} else if strings.Contains(strings.ToLower(filepath), "video") {
videoPath = filepath
} else {
retErr = fmt.Errorf("unexpcted file in zip %s", filepath)
}
}
return
}

145
cli/pkg/mapper/photo.go Normal file
View file

@ -0,0 +1,145 @@
package mapper
import (
"context"
"encoding/json"
"errors"
"github.com/ente-io/cli/internal/api"
eCrypto "github.com/ente-io/cli/internal/crypto"
"github.com/ente-io/cli/pkg/model"
"github.com/ente-io/cli/pkg/model/export"
"github.com/ente-io/cli/pkg/secrets"
"github.com/ente-io/cli/utils/encoding"
"log"
)
func MapCollectionToAlbum(ctx context.Context, collection api.Collection, holder *secrets.KeyHolder) (*model.RemoteAlbum, error) {
var album model.RemoteAlbum
userID := ctx.Value("user_id").(int64)
album.OwnerID = collection.Owner.ID
album.ID = collection.ID
album.IsShared = collection.Owner.ID != userID
album.LastUpdatedAt = collection.UpdationTime
album.IsDeleted = collection.IsDeleted
collectionKey, err := holder.GetCollectionKey(ctx, collection)
if err != nil {
return nil, err
}
album.AlbumKey = *model.MakeEncString(collectionKey, holder.DeviceKey)
var name string
if collection.EncryptedName != "" {
decrName, err := eCrypto.SecretBoxOpenBase64(collection.EncryptedName, collection.NameDecryptionNonce, collectionKey)
if err != nil {
log.Fatalf("failed to decrypt collection name: %v", err)
}
name = string(decrName)
} else {
// Early beta users (friends & family) might have collections without encrypted names
name = collection.Name
}
album.AlbumName = name
if collection.MagicMetadata != nil {
_, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(collection.MagicMetadata.Data, collectionKey, collection.MagicMetadata.Header)
if err != nil {
return nil, err
}
err = json.Unmarshal(encodedJsonBytes, &album.PrivateMeta)
if err != nil {
return nil, err
}
}
if collection.PublicMagicMetadata != nil {
_, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(collection.PublicMagicMetadata.Data, collectionKey, collection.PublicMagicMetadata.Header)
if err != nil {
return nil, err
}
err = json.Unmarshal(encodedJsonBytes, &album.PublicMeta)
if err != nil {
return nil, err
}
}
if album.IsShared && collection.SharedMagicMetadata != nil {
_, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(collection.SharedMagicMetadata.Data, collectionKey, collection.SharedMagicMetadata.Header)
if err != nil {
return nil, err
}
err = json.Unmarshal(encodedJsonBytes, &album.SharedMeta)
if err != nil {
return nil, err
}
}
return &album, nil
}
func MapApiFileToPhotoFile(ctx context.Context, album model.RemoteAlbum, file api.File, holder *secrets.KeyHolder) (*model.RemoteFile, error) {
if file.IsDeleted {
return nil, errors.New("file is deleted")
}
albumKey := album.AlbumKey.MustDecrypt(holder.DeviceKey)
fileKey, err := eCrypto.SecretBoxOpen(
encoding.DecodeBase64(file.EncryptedKey),
encoding.DecodeBase64(file.KeyDecryptionNonce),
albumKey)
if err != nil {
return nil, err
}
var photoFile model.RemoteFile
photoFile.ID = file.ID
photoFile.LastUpdateTime = file.UpdationTime
photoFile.Key = *model.MakeEncString(fileKey, holder.DeviceKey)
photoFile.FileNonce = file.File.DecryptionHeader
photoFile.ThumbnailNonce = file.Thumbnail.DecryptionHeader
photoFile.OwnerID = file.OwnerID
if file.Info != nil {
photoFile.Info = model.Info{
FileSize: file.Info.FileSize,
ThumbnailSize: file.Info.ThumbnailSize,
}
}
if file.Metadata.DecryptionHeader != "" {
_, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(file.Metadata.EncryptedData, fileKey, file.Metadata.DecryptionHeader)
if err != nil {
return nil, err
}
err = json.Unmarshal(encodedJsonBytes, &photoFile.Metadata)
if err != nil {
return nil, err
}
}
if file.MagicMetadata != nil {
_, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(file.MagicMetadata.Data, fileKey, file.MagicMetadata.Header)
if err != nil {
return nil, err
}
err = json.Unmarshal(encodedJsonBytes, &photoFile.PrivateMetadata)
if err != nil {
return nil, err
}
}
if file.PubicMagicMetadata != nil {
_, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(file.PubicMagicMetadata.Data, fileKey, file.PubicMagicMetadata.Header)
if err != nil {
return nil, err
}
err = json.Unmarshal(encodedJsonBytes, &photoFile.PublicMetadata)
if err != nil {
return nil, err
}
}
return &photoFile, nil
}
func MapRemoteFileToDiskMetadata(file model.RemoteFile) *export.DiskFileMetadata {
return &export.DiskFileMetadata{
Title: file.GetTitle(),
Description: file.GetCaption(),
CreationTime: file.GetCreationTime(),
ModificationTime: file.GetModificationTime(),
Location: file.GetLatlong(),
Info: &export.Info{
ID: file.ID,
Hash: file.GetFileHash(),
OwnerID: file.OwnerID,
},
}
}

39
cli/pkg/model/account.go Normal file
View file

@ -0,0 +1,39 @@
package model
import (
"fmt"
"github.com/ente-io/cli/internal/api"
)
type Account struct {
Email string `json:"email" binding:"required"`
UserID int64 `json:"userID" binding:"required"`
App api.App `json:"app" binding:"required"`
MasterKey EncString `json:"masterKey" binding:"required"`
SecretKey EncString `json:"secretKey" binding:"required"`
// PublicKey corresponding to the secret key
PublicKey string `json:"publicKey" binding:"required"`
Token EncString `json:"token" binding:"required"`
ExportDir string `json:"exportDir"`
}
type UpdateAccountParams struct {
Email string
App api.App
ExportDir *string
}
func (a *Account) AccountKey() string {
return fmt.Sprintf("%s-%d", a.App, a.UserID)
}
func (a *Account) DataBucket() string {
return fmt.Sprintf("%s-%d-data", a.App, a.UserID)
}
type AccSecretInfo struct {
MasterKey []byte
SecretKey []byte
Token []byte
PublicKey []byte
}

View file

@ -0,0 +1,15 @@
package model
type PhotosStore string
const (
KVConfig PhotosStore = "kvConfig"
RemoteAlbums PhotosStore = "remoteAlbums"
RemoteFiles PhotosStore = "remoteFiles"
RemoteAlbumEntries PhotosStore = "remoteAlbumEntries"
)
const (
CollectionsSyncKey = "lastCollectionSync"
CollectionsFileSyncKeyFmt = "collectionFilesSync-%d"
)

View file

@ -0,0 +1,31 @@
package model
import (
"github.com/ente-io/cli/internal/crypto"
"github.com/ente-io/cli/utils/encoding"
"log"
)
type EncString struct {
CipherText string `json:"cipherText"`
Nonce string `json:"nonce"`
}
func MakeEncString(plainTextBytes []byte, key []byte) *EncString {
cipher, nonce, err := crypto.EncryptChaCha20poly1305(plainTextBytes, key)
if err != nil {
log.Fatalf("failed to encrypt %s", err)
}
return &EncString{
CipherText: encoding.EncodeBase64(cipher),
Nonce: encoding.EncodeBase64(nonce),
}
}
func (e *EncString) MustDecrypt(key []byte) []byte {
_, plainBytes, err := crypto.DecryptChaChaBase64(e.CipherText, key, e.Nonce)
if err != nil {
panic(err)
}
return plainBytes
}

View file

@ -0,0 +1,20 @@
package model
import (
"crypto/rand"
"testing"
)
func TestEncString(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
if err != nil {
t.Fatalf("error generating key: %v", err)
}
data := "dataToEncrypt"
encData := MakeEncString([]byte(data), key)
decryptedData := encData.MustDecrypt(key)
if string(decryptedData) != data {
t.Fatalf("decrypted data is not equal to original data")
}
}

14
cli/pkg/model/errors.go Normal file
View file

@ -0,0 +1,14 @@
package model
import (
"errors"
"strings"
)
var ErrDecryption = errors.New("error while decrypting the file")
var ErrLiveZip = errors.New("error: no image or video file found in zip")
func ShouldRetrySync(err error) bool {
return strings.Contains(err.Error(), "read tcp") ||
strings.Contains(err.Error(), "dial tcp")
}

View file

@ -0,0 +1,6 @@
package export
type Location struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}

View file

@ -0,0 +1,64 @@
package export
import "time"
type AlbumMetadata struct {
ID int64 `json:"id"`
OwnerID int64 `json:"ownerID"`
AlbumName string `json:"albumName"`
IsDeleted bool `json:"isDeleted"`
// This is to handle the case where two accounts are exporting to the same directory
// and a album is shared between them
AccountOwnerIDs []int64 `json:"accountOwnerIDs"`
// Folder name is the name of the disk folder that contains the album data
// exclude this from json serialization
FolderName string `json:"-"`
}
// AddAccountOwner adds the given account id to the list of account owners
// if it is not already present. Returns true if the account id was added
// and false otherwise
func (a *AlbumMetadata) AddAccountOwner(id int64) bool {
for _, ownerID := range a.AccountOwnerIDs {
if ownerID == id {
return false
}
}
a.AccountOwnerIDs = append(a.AccountOwnerIDs, id)
return true
}
// DiskFileMetadata is the metadata for a file when exported to disk
// For S3 compliant storage, we will introduce a new struct that will contain references to the albums
type DiskFileMetadata struct {
Title string `json:"title"`
Description *string `json:"description"`
Location *Location `json:"location"`
CreationTime time.Time `json:"creationTime"`
ModificationTime time.Time `json:"modificationTime"`
Info *Info `json:"info"`
// exclude this from json serialization
MetaFileName string `json:"-"`
}
func (d *DiskFileMetadata) AddFileName(fileName string) {
if d.Info.FileNames == nil {
d.Info.FileNames = make([]string, 0)
}
for _, ownerID := range d.Info.FileNames {
if ownerID == fileName {
return
}
}
d.Info.FileNames = append(d.Info.FileNames, fileName)
}
type Info struct {
ID int64 `json:"id"`
Hash *string `json:"hash"`
OwnerID int64 `json:"ownerID"`
// A file can contain multiple parts (example: live photos or burst photos)
FileNames []string `json:"fileNames"`
}

176
cli/pkg/model/remote.go Normal file
View file

@ -0,0 +1,176 @@
package model
import (
"fmt"
"github.com/ente-io/cli/pkg/model/export"
"sort"
"time"
)
type FileType int8
const (
Image FileType = iota
Video
LivePhoto
Unknown = 127
)
type RemoteFile struct {
ID int64 `json:"id"`
OwnerID int64 `json:"ownerID"`
Key EncString `json:"key"`
LastUpdateTime int64 `json:"lastUpdateTime"`
FileNonce string `json:"fileNonce"`
ThumbnailNonce string `json:"thumbnailNonce"`
Metadata map[string]interface{} `json:"metadata"`
PrivateMetadata map[string]interface{} `json:"privateMetadata"`
PublicMetadata map[string]interface{} `json:"publicMetadata"`
Info Info `json:"info"`
}
type Info struct {
FileSize int64 `json:"fileSize,omitempty"`
ThumbnailSize int64 `json:"thumbSize,omitempty"`
}
type RemoteAlbum struct {
ID int64 `json:"id"`
OwnerID int64 `json:"ownerID"`
IsShared bool `json:"isShared"`
IsDeleted bool `json:"isDeleted"`
AlbumName string `json:"albumName"`
AlbumKey EncString `json:"albumKey"`
PublicMeta map[string]interface{} `json:"publicMeta"`
PrivateMeta map[string]interface{} `json:"privateMeta"`
SharedMeta map[string]interface{} `json:"sharedMeta"`
LastUpdatedAt int64 `json:"lastUpdatedAt"`
}
type AlbumFileEntry struct {
FileID int64 `json:"fileID"`
AlbumID int64 `json:"albumID"`
IsDeleted bool `json:"isDeleted"`
SyncedLocally bool `json:"localSync"`
}
// SortAlbumFileEntry sorts the given entries by isDeleted and then by albumID
func SortAlbumFileEntry(entries []*AlbumFileEntry) {
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDeleted != entries[j].IsDeleted {
return !entries[i].IsDeleted && entries[j].IsDeleted
}
return entries[i].AlbumID < entries[j].AlbumID
})
}
func (r *RemoteFile) GetFileType() FileType {
value, ok := r.Metadata["fileType"]
if !ok {
panic("fileType not found in metadata")
}
switch int8(value.(float64)) {
case 0:
return Image
case 1:
return Video
case 2:
return LivePhoto
}
panic(fmt.Sprintf("invalid fileType %d", value.(int8)))
}
func (r *RemoteFile) IsLivePhoto() bool {
return r.GetFileType() == LivePhoto
}
func (r *RemoteFile) GetFileHash() *string {
value, ok := r.Metadata["hash"]
if !ok {
if r.IsLivePhoto() {
imageHash, hasImgHash := r.Metadata["imageHash"]
vidHash, hasVidHash := r.Metadata["videoHash"]
if hasImgHash && hasVidHash {
hash := fmt.Sprintf("%s:%s", imageHash, vidHash)
return &hash
}
}
return nil
}
if str, ok := value.(string); ok {
return &str
}
return nil
}
func (r *RemoteFile) GetTitle() string {
if r.PublicMetadata != nil {
if value, ok := r.PublicMetadata["editedName"]; ok {
return value.(string)
}
}
value, ok := r.Metadata["title"]
if !ok {
panic("title not found in metadata")
}
return value.(string)
}
func (r *RemoteFile) GetCaption() *string {
if r.PublicMetadata != nil {
if value, ok := r.PublicMetadata["caption"]; ok {
if str, ok := value.(string); ok {
return &str
}
}
}
return nil
}
func (r *RemoteFile) GetCreationTime() time.Time {
if r.PublicMetadata != nil {
if value, ok := r.PublicMetadata["editedTime"]; ok && value.(float64) != 0 {
return time.UnixMicro(int64(value.(float64)))
}
}
value, ok := r.Metadata["creationTime"]
if !ok {
panic("creationTime not found in metadata")
}
return time.UnixMicro(int64(value.(float64)))
}
func (r *RemoteFile) GetModificationTime() time.Time {
value, ok := r.Metadata["modificationTime"]
if !ok {
panic("creationTime not found in metadata")
}
return time.UnixMicro(int64(value.(float64)))
}
func (r *RemoteFile) GetLatlong() *export.Location {
if r.PublicMetadata != nil {
// check if lat and long key exists
if lat, ok := r.PublicMetadata["lat"]; ok {
if long, ok := r.PublicMetadata["long"]; ok {
if lat.(float64) == 0 && long.(float64) == 0 {
return nil
}
return &export.Location{
Latitude: lat.(float64),
Longitude: long.(float64),
}
}
}
}
if lat, ok := r.Metadata["latitude"]; ok && lat != nil {
if long, ok2 := r.Metadata["longitude"]; ok2 && long != nil {
return &export.Location{
Latitude: lat.(float64),
Longitude: long.(float64),
}
}
}
return nil
}

179
cli/pkg/remote_sync.go Normal file
View file

@ -0,0 +1,179 @@
package pkg
import (
"context"
"encoding/json"
"fmt"
"github.com/ente-io/cli/pkg/mapper"
"github.com/ente-io/cli/pkg/model"
"github.com/ente-io/cli/utils/encoding"
"log"
"strconv"
"time"
)
func (c *ClICtrl) fetchRemoteCollections(ctx context.Context) error {
lastSyncTime, err2 := c.GetInt64ConfigValue(ctx, model.CollectionsSyncKey)
if err2 != nil {
return err2
}
collections, err := c.Client.GetCollections(ctx, lastSyncTime)
if err != nil {
return fmt.Errorf("failed to get collections: %s", err)
}
maxUpdated := lastSyncTime
for _, collection := range collections {
if lastSyncTime == 0 && collection.IsDeleted {
continue
}
album, mapErr := mapper.MapCollectionToAlbum(ctx, collection, c.KeyHolder)
if mapErr != nil {
return mapErr
}
if album.LastUpdatedAt > maxUpdated {
maxUpdated = album.LastUpdatedAt
}
albumJson := encoding.MustMarshalJSON(album)
putErr := c.PutValue(ctx, model.RemoteAlbums, []byte(strconv.FormatInt(album.ID, 10)), albumJson)
if putErr != nil {
return putErr
}
}
if maxUpdated > lastSyncTime {
err = c.PutConfigValue(ctx, model.CollectionsSyncKey, []byte(strconv.FormatInt(maxUpdated, 10)))
if err != nil {
return fmt.Errorf("failed to update last sync time: %s", err)
}
}
return nil
}
func (c *ClICtrl) fetchRemoteFiles(ctx context.Context) error {
albums, err := c.getRemoteAlbums(ctx)
if err != nil {
return err
}
for _, album := range albums {
if album.IsDeleted {
continue
}
lastSyncTime, lastSyncTimeErr := c.GetInt64ConfigValue(ctx, fmt.Sprintf(model.CollectionsFileSyncKeyFmt, album.ID))
if lastSyncTimeErr != nil {
return lastSyncTimeErr
}
isFirstSync := lastSyncTime == 0
for {
if lastSyncTime == album.LastUpdatedAt {
break
}
if isFirstSync {
log.Printf("Sync files metadata for album %s\n", album.AlbumName)
} else {
log.Printf("Sync files metadata for album %s\n from %s", album.AlbumName, time.UnixMicro(lastSyncTime))
}
if !isFirstSync {
t := time.UnixMicro(lastSyncTime)
log.Printf("Fetching files metadata for album %s from %v\n", album.AlbumName, t)
}
files, hasMore, err := c.Client.GetFiles(ctx, album.ID, lastSyncTime)
if err != nil {
return err
}
maxUpdated := lastSyncTime
for _, file := range files {
if file.UpdationTime > maxUpdated {
maxUpdated = file.UpdationTime
}
if isFirstSync && file.IsDeleted {
// on first sync, no need to sync delete markers
continue
}
albumEntry := model.AlbumFileEntry{AlbumID: album.ID, FileID: file.ID, IsDeleted: file.IsDeleted, SyncedLocally: false}
putErr := c.UpsertAlbumEntry(ctx, &albumEntry)
if putErr != nil {
return putErr
}
if file.IsDeleted {
continue
}
photoFile, err := mapper.MapApiFileToPhotoFile(ctx, album, file, c.KeyHolder)
if err != nil {
return err
}
fileJson := encoding.MustMarshalJSON(photoFile)
// todo: use batch put
putErr = c.PutValue(ctx, model.RemoteFiles, []byte(strconv.FormatInt(file.ID, 10)), fileJson)
if putErr != nil {
return putErr
}
}
if !hasMore {
maxUpdated = album.LastUpdatedAt
}
if (maxUpdated > lastSyncTime) || !hasMore {
log.Printf("Updating last sync time for album %s to %s\n", album.AlbumName, time.UnixMicro(maxUpdated))
err = c.PutConfigValue(ctx, fmt.Sprintf(model.CollectionsFileSyncKeyFmt, album.ID), []byte(strconv.FormatInt(maxUpdated, 10)))
if err != nil {
return fmt.Errorf("failed to update last sync time: %s", err)
} else {
lastSyncTime = maxUpdated
}
}
}
}
return nil
}
func (c *ClICtrl) getRemoteAlbums(ctx context.Context) ([]model.RemoteAlbum, error) {
albums := make([]model.RemoteAlbum, 0)
albumBytes, err := c.GetAllValues(ctx, model.RemoteAlbums)
if err != nil {
return nil, err
}
for _, albumJson := range albumBytes {
album := model.RemoteAlbum{}
err = json.Unmarshal(albumJson, &album)
if err != nil {
return nil, err
}
albums = append(albums, album)
}
return albums, nil
}
func (c *ClICtrl) getRemoteFiles(ctx context.Context) ([]model.RemoteFile, error) {
files := make([]model.RemoteFile, 0)
fileBytes, err := c.GetAllValues(ctx, model.RemoteFiles)
if err != nil {
return nil, err
}
for _, fileJson := range fileBytes {
file := model.RemoteFile{}
err = json.Unmarshal(fileJson, &file)
if err != nil {
return nil, err
}
files = append(files, file)
}
return files, nil
}
func (c *ClICtrl) getRemoteAlbumEntries(ctx context.Context) ([]*model.AlbumFileEntry, error) {
entries := make([]*model.AlbumFileEntry, 0)
entryBytes, err := c.GetAllValues(ctx, model.RemoteAlbumEntries)
if err != nil {
return nil, err
}
for _, entryJson := range entryBytes {
entry := &model.AlbumFileEntry{}
err = json.Unmarshal(entryJson, &entry)
if err != nil {
return nil, err
}
entries = append(entries, entry)
}
return entries, nil
}

View file

@ -0,0 +1,138 @@
package pkg
import (
"context"
"encoding/json"
"fmt"
"github.com/ente-io/cli/pkg/model"
"github.com/ente-io/cli/pkg/model/export"
"log"
"os"
"strings"
"path/filepath"
)
func (c *ClICtrl) createLocalFolderForRemoteAlbums(ctx context.Context, account model.Account) error {
path := account.ExportDir
albums, err := c.getRemoteAlbums(ctx)
if err != nil {
return err
}
userID := ctx.Value("user_id").(int64)
folderToMetaMap, albumIDToMetaMap, err := readFolderMetadata(path)
if err != nil {
return err
}
for _, album := range albums {
if album.IsDeleted {
if meta, ok := albumIDToMetaMap[album.ID]; ok {
log.Printf("Deleting album %s as it is deleted", meta.AlbumName)
if err = os.RemoveAll(filepath.Join(path, meta.FolderName)); err != nil {
return err
}
delete(folderToMetaMap, meta.FolderName)
delete(albumIDToMetaMap, meta.ID)
}
continue
}
metaByID := albumIDToMetaMap[album.ID]
if metaByID != nil {
if strings.EqualFold(metaByID.AlbumName, album.AlbumName) {
//log.Printf("Skipping album %s as it already exists", album.AlbumName)
continue
}
}
albumFolderName := filepath.Clean(album.AlbumName)
// replace : with _
albumFolderName = strings.ReplaceAll(albumFolderName, ":", "_")
albumFolderName = strings.ReplaceAll(albumFolderName, "/", "_")
albumFolderName = strings.TrimSpace(albumFolderName)
albumID := album.ID
if _, ok := folderToMetaMap[albumFolderName]; ok {
for i := 1; ; i++ {
newAlbumName := fmt.Sprintf("%s_%d", albumFolderName, i)
if _, ok := folderToMetaMap[newAlbumName]; !ok {
albumFolderName = newAlbumName
break
}
}
}
// Create album and meta folders if they don't exist
albumPath := filepath.Clean(filepath.Join(path, albumFolderName))
metaPath := filepath.Join(albumPath, ".meta")
if metaByID == nil {
log.Printf("Adding folder %s for album %s", albumFolderName, album.AlbumName)
for _, p := range []string{albumPath, metaPath} {
if _, err := os.Stat(p); os.IsNotExist(err) {
if err = os.Mkdir(p, 0755); err != nil {
return err
}
}
}
} else {
// rename meta.FolderName to albumFolderName
oldAlbumPath := filepath.Join(path, metaByID.FolderName)
log.Printf("Renaming path from %s to %s for album %s", oldAlbumPath, albumPath, album.AlbumName)
if err = os.Rename(oldAlbumPath, albumPath); err != nil {
return err
}
}
// Handle meta file
metaFilePath := filepath.Join(path, albumFolderName, albumMetaFolder, albumMetaFile)
metaData := export.AlbumMetadata{
ID: album.ID,
OwnerID: album.OwnerID,
AlbumName: album.AlbumName,
IsDeleted: album.IsDeleted,
AccountOwnerIDs: []int64{userID},
FolderName: albumFolderName,
}
if err = writeJSONToFile(metaFilePath, metaData); err != nil {
return err
}
folderToMetaMap[albumFolderName] = &metaData
albumIDToMetaMap[albumID] = &metaData
}
return nil
}
// readFolderMetadata returns a map of folder name to album metadata for all folders in the given path
// and a map of album ID to album metadata for all albums in the given path.
func readFolderMetadata(path string) (map[string]*export.AlbumMetadata, map[int64]*export.AlbumMetadata, error) {
result := make(map[string]*export.AlbumMetadata)
albumIdToMetadataMap := make(map[int64]*export.AlbumMetadata)
// Read the top-level directories in the given path
entries, err := os.ReadDir(path)
if err != nil {
return nil, nil, err
}
for _, entry := range entries {
if entry.IsDir() {
dirName := entry.Name()
metaFilePath := filepath.Join(path, dirName, albumMetaFolder, albumMetaFile)
// Initialize as nil, will remain nil if JSON file is not found or not readable
result[dirName] = nil
// Read the JSON file if it exists
if _, err := os.Stat(metaFilePath); err == nil {
var metaData export.AlbumMetadata
metaDataBytes, err := os.ReadFile(metaFilePath)
if err != nil {
continue // Skip this entry if reading fails
}
if err := json.Unmarshal(metaDataBytes, &metaData); err == nil {
metaData.FolderName = dirName
result[dirName] = &metaData
albumIdToMetadataMap[metaData.ID] = &metaData
}
}
}
}
return result, albumIdToMetadataMap, nil
}

View file

@ -0,0 +1,275 @@
package pkg
import (
"archive/zip"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/ente-io/cli/pkg/mapper"
"github.com/ente-io/cli/pkg/model"
"github.com/ente-io/cli/pkg/model/export"
"github.com/ente-io/cli/utils"
"log"
"os"
"path/filepath"
"strings"
"time"
)
func (c *ClICtrl) syncFiles(ctx context.Context, account model.Account) error {
log.Printf("Starting file download")
exportRoot := account.ExportDir
_, albumIDToMetaMap, err := readFolderMetadata(exportRoot)
if err != nil {
return err
}
entries, err := c.getRemoteAlbumEntries(ctx)
if err != nil {
return err
}
log.Println("total entries", len(entries))
model.SortAlbumFileEntry(entries)
defer utils.TimeTrack(time.Now(), "process_files")
var albumDiskInfo *albumDiskInfo
for i, albumFileEntry := range entries {
if albumFileEntry.SyncedLocally {
continue
}
albumInfo, ok := albumIDToMetaMap[albumFileEntry.AlbumID]
if !ok {
log.Printf("Album %d not found in local metadata", albumFileEntry.AlbumID)
continue
}
if albumInfo.IsDeleted {
putErr := c.DeleteAlbumEntry(ctx, albumFileEntry)
if putErr != nil {
return putErr
}
continue
}
if albumDiskInfo == nil || albumDiskInfo.AlbumMeta.ID != albumInfo.ID {
albumDiskInfo, err = readFilesMetadata(exportRoot, albumInfo)
if err != nil {
return err
}
}
fileBytes, err := c.GetValue(ctx, model.RemoteFiles, []byte(fmt.Sprintf("%d", albumFileEntry.FileID)))
if err != nil {
return err
}
if fileBytes != nil {
var existingEntry *model.RemoteFile
err = json.Unmarshal(fileBytes, &existingEntry)
if err != nil {
return err
}
log.Printf("[%d/%d] Sync %s for album %s", i, len(entries), existingEntry.GetTitle(), albumInfo.AlbumName)
err = c.downloadEntry(ctx, albumDiskInfo, *existingEntry, albumFileEntry)
if err != nil {
if errors.Is(err, model.ErrDecryption) {
continue
} else if existingEntry.IsLivePhoto() && errors.Is(err, zip.ErrFormat) {
log.Printf(fmt.Sprintf("err processing live photo %s (%d), %s", existingEntry.GetTitle(), existingEntry.ID, err.Error()))
continue
} else if existingEntry.IsLivePhoto() && errors.Is(err, model.ErrLiveZip) {
continue
} else {
return err
}
}
} else {
// file metadata is missing in the localDB
if albumFileEntry.IsDeleted {
delErr := c.DeleteAlbumEntry(ctx, albumFileEntry)
if delErr != nil {
log.Fatalf("Error deleting album entry %d (deleted: %v) %v", albumFileEntry.FileID, albumFileEntry.IsDeleted, delErr)
}
} else {
log.Fatalf("Failed to find entry in db for file %d (deleted: %v)", albumFileEntry.FileID, albumFileEntry.IsDeleted)
}
}
}
return nil
}
func (c *ClICtrl) downloadEntry(ctx context.Context,
diskInfo *albumDiskInfo,
file model.RemoteFile,
albumEntry *model.AlbumFileEntry,
) error {
if !diskInfo.AlbumMeta.IsDeleted && albumEntry.IsDeleted {
albumEntry.IsDeleted = true
diskFileMeta := diskInfo.GetDiskFileMetadata(file)
if diskFileMeta != nil {
removeErr := removeDiskFile(diskFileMeta, diskInfo)
if removeErr != nil {
return removeErr
}
}
delErr := c.DeleteAlbumEntry(ctx, albumEntry)
if delErr != nil {
return delErr
}
return nil
}
diskFileMeta := diskInfo.GetDiskFileMetadata(file)
if diskFileMeta != nil {
removeErr := removeDiskFile(diskFileMeta, diskInfo)
if removeErr != nil {
return removeErr
}
}
if !diskInfo.IsFilePresent(file) {
decrypt, err := c.downloadAndDecrypt(ctx, file, c.KeyHolder.DeviceKey)
if err != nil {
return err
}
fileDiskMetadata := mapper.MapRemoteFileToDiskMetadata(file)
// Get the extension
extension := filepath.Ext(fileDiskMetadata.Title)
baseFileName := strings.TrimSuffix(filepath.Clean(filepath.Base(fileDiskMetadata.Title)), extension)
diskMetaFileName := diskInfo.GenerateUniqueMetaFileName(baseFileName, extension)
if file.IsLivePhoto() {
imagePath, videoPath, err := UnpackLive(*decrypt)
if err != nil {
return err
}
if imagePath == "" && videoPath == "" {
log.Printf("imagePath %s, videoPath %s", imagePath, videoPath)
return model.ErrLiveZip
}
if imagePath != "" {
imageExtn := filepath.Ext(imagePath)
imageFileName := diskInfo.GenerateUniqueFileName(baseFileName, imageExtn)
imageFilePath := filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, imageFileName)
moveErr := Move(imagePath, imageFilePath)
if moveErr != nil {
return moveErr
}
fileDiskMetadata.AddFileName(imageFileName)
}
if videoPath == "" {
videoExtn := filepath.Ext(videoPath)
videoFileName := diskInfo.GenerateUniqueFileName(baseFileName, videoExtn)
videoFilePath := filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, videoFileName)
// move the decrypt file to filePath
moveErr := Move(videoPath, videoFilePath)
if moveErr != nil {
return moveErr
}
fileDiskMetadata.AddFileName(videoFileName)
}
} else {
fileName := diskInfo.GenerateUniqueFileName(baseFileName, extension)
filePath := filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, fileName)
// move the decrypt file to filePath
err = Move(*decrypt, filePath)
if err != nil {
return err
}
fileDiskMetadata.AddFileName(fileName)
}
fileDiskMetadata.MetaFileName = diskMetaFileName
err = diskInfo.AddEntry(fileDiskMetadata)
if err != nil {
return err
}
err = writeJSONToFile(filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, ".meta", diskMetaFileName), fileDiskMetadata)
if err != nil {
return err
}
albumEntry.SyncedLocally = true
putErr := c.UpsertAlbumEntry(ctx, albumEntry)
if putErr != nil {
return putErr
}
}
return nil
}
func removeDiskFile(diskFileMeta *export.DiskFileMetadata, diskInfo *albumDiskInfo) error {
// remove the file from disk
log.Printf("Removing file %s from disk", diskFileMeta.MetaFileName)
err := os.Remove(filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, ".meta", diskFileMeta.MetaFileName))
if err != nil && !os.IsNotExist(err) {
return err
}
for _, fileName := range diskFileMeta.Info.FileNames {
err = os.Remove(filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, fileName))
if err != nil && !os.IsNotExist(err) {
return err
}
}
return diskInfo.RemoveEntry(diskFileMeta)
}
// readFolderMetadata reads the metadata of the files in the given path
// For disk export, a particular albums files are stored in a folder named after the album.
// Inside the folder, the files are stored at top level and its metadata is stored in a .meta folder
func readFilesMetadata(home string, albumMeta *export.AlbumMetadata) (*albumDiskInfo, error) {
albumMetadataFolder := filepath.Join(home, albumMeta.FolderName, albumMetaFolder)
albumPath := filepath.Join(home, albumMeta.FolderName)
// verify the both the album folder and the .meta folder exist
if _, err := os.Stat(albumMetadataFolder); err != nil {
return nil, err
}
if _, err := os.Stat(albumPath); err != nil {
return nil, err
}
result := make(map[string]*export.DiskFileMetadata)
//fileNameToFileName := make(map[string]*export.DiskFileMetadata)
fileIdToMetadata := make(map[int64]*export.DiskFileMetadata)
claimedFileName := make(map[string]bool)
// Read the top-level directories in the given path
albumFileEntries, err := os.ReadDir(albumPath)
if err != nil {
return nil, err
}
for _, entry := range albumFileEntries {
if !entry.IsDir() {
claimedFileName[strings.ToLower(entry.Name())] = true
}
}
metaEntries, err := os.ReadDir(albumMetadataFolder)
if err != nil {
return nil, err
}
for _, entry := range metaEntries {
if !entry.IsDir() {
fileName := entry.Name()
if fileName == albumMetaFile {
continue
}
if !strings.HasSuffix(fileName, ".json") {
log.Printf("Skipping file %s as it is not a JSON file", fileName)
continue
}
fileMetadataPath := filepath.Join(albumMetadataFolder, fileName)
// Initialize as nil, will remain nil if JSON file is not found or not readable
result[strings.ToLower(fileName)] = nil
// Read the JSON file if it exists
var metaData export.DiskFileMetadata
metaDataBytes, err := os.ReadFile(fileMetadataPath)
if err != nil {
continue // Skip this entry if reading fails
}
if err := json.Unmarshal(metaDataBytes, &metaData); err == nil {
metaData.MetaFileName = fileName
result[strings.ToLower(fileName)] = &metaData
fileIdToMetadata[metaData.Info.ID] = &metaData
}
}
}
return &albumDiskInfo{
ExportRoot: home,
AlbumMeta: albumMeta,
FileNames: &claimedFileName,
MetaFileNameToDiskFileMap: &result,
FileIdToDiskFileMap: &fileIdToMetadata,
}, nil
}

View file

@ -0,0 +1,75 @@
package secrets
import (
"context"
"fmt"
"github.com/ente-io/cli/internal/api"
eCrypto "github.com/ente-io/cli/internal/crypto"
"github.com/ente-io/cli/pkg/model"
"github.com/ente-io/cli/utils/encoding"
)
type KeyHolder struct {
// DeviceKey is the key used to encrypt/decrypt the data while storing sensitive
// information on the disk. Usually, it should be stored in OS Keychain.
DeviceKey []byte
AccountSecrets map[string]*model.AccSecretInfo
CollectionKeys map[string][]byte
}
func NewKeyHolder(deviceKey []byte) *KeyHolder {
return &KeyHolder{
AccountSecrets: make(map[string]*model.AccSecretInfo),
CollectionKeys: make(map[string][]byte),
DeviceKey: deviceKey,
}
}
// LoadSecrets loads the secrets for a given account using the provided CLI key.
// It decrypts the token key, master key, and secret key using the CLI key.
// The decrypted keys and the decoded public key are stored in the AccountSecrets map using the account key as the map key.
// It returns the account secret information or an error if the decryption fails.
func (k *KeyHolder) LoadSecrets(account model.Account) (*model.AccSecretInfo, error) {
tokenKey := account.Token.MustDecrypt(k.DeviceKey)
masterKey := account.MasterKey.MustDecrypt(k.DeviceKey)
secretKey := account.SecretKey.MustDecrypt(k.DeviceKey)
k.AccountSecrets[account.AccountKey()] = &model.AccSecretInfo{
Token: tokenKey,
MasterKey: masterKey,
SecretKey: secretKey,
PublicKey: encoding.DecodeBase64(account.PublicKey),
}
return k.AccountSecrets[account.AccountKey()], nil
}
func (k *KeyHolder) GetAccountSecretInfo(ctx context.Context) *model.AccSecretInfo {
accountKey := ctx.Value("account_key").(string)
return k.AccountSecrets[accountKey]
}
// GetCollectionKey retrieves the key for a given collection.
// It first fetches the account secret information from the context.
// If the collection owner's ID matches the user ID from the context, it decrypts the collection key using the master key.
// If the collection is shared (i.e., the owner's ID does not match the user ID), it decrypts the collection key using the public and secret keys.
// It returns the decrypted collection key or an error if the decryption fails.
func (k *KeyHolder) GetCollectionKey(ctx context.Context, collection api.Collection) ([]byte, error) {
accSecretInfo := k.GetAccountSecretInfo(ctx)
userID := ctx.Value("user_id").(int64)
if collection.Owner.ID == userID {
collKey, err := eCrypto.SecretBoxOpen(
encoding.DecodeBase64(collection.EncryptedKey),
encoding.DecodeBase64(collection.KeyDecryptionNonce),
accSecretInfo.MasterKey)
if err != nil {
return nil, fmt.Errorf("collection %d key drive failed %s", collection.ID, err)
}
return collKey, nil
} else {
collKey, err := eCrypto.SealedBoxOpen(encoding.DecodeBase64(collection.EncryptedKey),
accSecretInfo.PublicKey, accSecretInfo.SecretKey)
if err != nil {
return nil, fmt.Errorf("shared collection %d key drive failed %s", collection.ID, err)
}
return collKey, nil
}
}

82
cli/pkg/secrets/secret.go Normal file
View file

@ -0,0 +1,82 @@
package secrets
import (
"crypto/rand"
"errors"
"fmt"
"github.com/ente-io/cli/utils/constants"
"log"
"os"
"github.com/zalando/go-keyring"
)
func IsRunningInContainer() bool {
if _, err := os.Stat("/.dockerenv"); err != nil {
return false
}
return true
}
const (
secretService = "ente"
secretUser = "ente-cli-user"
)
func GetOrCreateClISecret() []byte {
// get password
secret, err := keyring.Get(secretService, secretUser)
if err != nil {
if !errors.Is(err, keyring.ErrNotFound) {
if IsRunningInContainer() {
return GetSecretFromSecretText()
} else {
log.Fatal(fmt.Errorf("error getting password from keyring: %w", err))
}
}
key := make([]byte, 32)
_, err = rand.Read(key)
if err != nil {
log.Fatal(fmt.Errorf("error generating key: %w", err))
}
secret = string(key)
keySetErr := keyring.Set(secretService, secretUser, string(secret))
if keySetErr != nil {
log.Fatal(fmt.Errorf("error setting password in keyring: %w", keySetErr))
}
}
return []byte(secret)
}
// GetSecretFromSecretText reads the scecret from the secret text file.
// If the file does not exist, it will be created and write random 32 byte secret to it.
func GetSecretFromSecretText() []byte {
// Define the path to the secret text file
secretFilePath := fmt.Sprintf("%s.secret.txt", constants.CliDataPath)
// Check if file exists
_, err := os.Stat(secretFilePath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
log.Fatal(fmt.Errorf("error checking secret file: %w", err))
}
// File does not exist; create and write a random 32-byte secret
key := make([]byte, 32)
_, err := rand.Read(key)
if err != nil {
log.Fatal(fmt.Errorf("error generating key: %w", err))
}
err = os.WriteFile(secretFilePath, key, 0644)
if err != nil {
log.Fatal(fmt.Errorf("error writing to secret file: %w", err))
}
return key
}
// File exists; read the secret
secret, err := os.ReadFile(secretFilePath)
if err != nil {
log.Fatal(fmt.Errorf("error reading from secret file: %w", err))
}
return secret
}

160
cli/pkg/sign_in.go Normal file
View file

@ -0,0 +1,160 @@
package pkg
import (
"context"
"fmt"
"github.com/ente-io/cli/internal"
"github.com/ente-io/cli/internal/api"
eCrypto "github.com/ente-io/cli/internal/crypto"
"github.com/ente-io/cli/pkg/model"
"github.com/ente-io/cli/utils/encoding"
"log"
"github.com/kong/go-srp"
)
func (c *ClICtrl) signInViaPassword(ctx context.Context, srpAttr *api.SRPAttributes) (*api.AuthorizationResponse, []byte, error) {
for {
// CLI prompt for password
password, flowErr := internal.GetSensitiveField("Enter password")
if flowErr != nil {
return nil, nil, flowErr
}
fmt.Println("\nPlease wait authenticating...")
keyEncKey, err := eCrypto.DeriveArgonKey(password, srpAttr.KekSalt, srpAttr.MemLimit, srpAttr.OpsLimit)
if err != nil {
fmt.Printf("error deriving key encryption key: %v", err)
return nil, nil, err
}
loginKey := eCrypto.DeriveLoginKey(keyEncKey)
srpParams := srp.GetParams(4096)
identify := []byte(srpAttr.SRPUserID.String())
salt := encoding.DecodeBase64(srpAttr.SRPSalt)
clientSecret := srp.GenKey()
srpClient := srp.NewClient(srpParams, salt, identify, loginKey, clientSecret)
clientA := srpClient.ComputeA()
session, err := c.Client.CreateSRPSession(ctx, srpAttr.SRPUserID, encoding.EncodeBase64(clientA))
if err != nil {
return nil, nil, err
}
serverB := session.SRPB
srpClient.SetB(encoding.DecodeBase64(serverB))
clientM := srpClient.ComputeM1()
authResp, err := c.Client.VerifySRPSession(ctx, srpAttr.SRPUserID, session.SessionID, encoding.EncodeBase64(clientM))
if err != nil {
log.Printf("failed to verify %v", err)
continue
}
return authResp, keyEncKey, nil
}
}
// Parameters:
// - keyEncKey: key encryption key is derived from user's password. During SRP based login, this key is already derived.
// So, we can pass it to avoid asking for password again.
func (c *ClICtrl) decryptAccSecretInfo(
_ context.Context,
authResp *api.AuthorizationResponse,
keyEncKey []byte,
) (*model.AccSecretInfo, error) {
var currentKeyEncKey []byte
var err error
var masterKey, secretKey, tokenKey []byte
var publicKey = encoding.DecodeBase64(authResp.KeyAttributes.PublicKey)
for {
if keyEncKey == nil {
// CLI prompt for password
password, flowErr := internal.GetSensitiveField("Enter password")
if flowErr != nil {
return nil, flowErr
}
fmt.Println("\nPlease wait authenticating...")
currentKeyEncKey, err = eCrypto.DeriveArgonKey(password,
authResp.KeyAttributes.KEKSalt, authResp.KeyAttributes.MemLimit, authResp.KeyAttributes.OpsLimit)
if err != nil {
fmt.Printf("error deriving key encryption key: %v", err)
return nil, err
}
} else {
currentKeyEncKey = keyEncKey
}
encryptedKey := encoding.DecodeBase64(authResp.KeyAttributes.EncryptedKey)
encryptedKeyNonce := encoding.DecodeBase64(authResp.KeyAttributes.KeyDecryptionNonce)
masterKey, err = eCrypto.SecretBoxOpen(encryptedKey, encryptedKeyNonce, currentKeyEncKey)
if err != nil {
if keyEncKey != nil {
fmt.Printf("Failed to get key from keyEncryptionKey %s", err)
return nil, err
} else {
fmt.Printf("Incorrect password, error decrypting master key: %v", err)
continue
}
}
secretKey, err = eCrypto.SecretBoxOpen(
encoding.DecodeBase64(authResp.KeyAttributes.EncryptedSecretKey),
encoding.DecodeBase64(authResp.KeyAttributes.SecretKeyDecryptionNonce),
masterKey,
)
if err != nil {
fmt.Printf("error decrypting master key: %v", err)
return nil, err
}
tokenKey, err = eCrypto.SealedBoxOpen(
encoding.DecodeBase64(authResp.EncryptedToken),
publicKey,
secretKey,
)
if err != nil {
fmt.Printf("error decrypting token: %v", err)
return nil, err
}
break
}
return &model.AccSecretInfo{
MasterKey: masterKey,
SecretKey: secretKey,
Token: tokenKey,
PublicKey: publicKey,
}, nil
}
func (c *ClICtrl) validateTOTP(ctx context.Context, authResp *api.AuthorizationResponse) (*api.AuthorizationResponse, error) {
if !authResp.IsMFARequired() {
return authResp, nil
}
for {
// CLI prompt for TOTP
totp, flowErr := internal.GetCode("Enter TOTP", 6)
if flowErr != nil {
return nil, flowErr
}
totpResp, err := c.Client.VerifyTotp(ctx, authResp.TwoFactorSessionID, totp)
if err != nil {
log.Printf("failed to verify %v", err)
continue
}
return totpResp, nil
}
}
func (c *ClICtrl) validateEmail(ctx context.Context, email string) (*api.AuthorizationResponse, error) {
err := c.Client.SendEmailOTP(ctx, email)
if err != nil {
return nil, err
}
for {
// CLI prompt for OTP
ott, flowErr := internal.GetCode("Enter OTP", 6)
if flowErr != nil {
return nil, flowErr
}
authResponse, err := c.Client.VerifyEmail(ctx, email, ott)
if err != nil {
log.Printf("failed to verify %v", err)
continue
}
return authResponse, nil
}
}

119
cli/pkg/store.go Normal file
View file

@ -0,0 +1,119 @@
package pkg
import (
"context"
"fmt"
"github.com/ente-io/cli/pkg/model"
"log"
"strconv"
"time"
bolt "go.etcd.io/bbolt"
)
func GetDB(path string) (*bolt.DB, error) {
db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
log.Fatal(err)
}
return db, err
}
func (c *ClICtrl) GetInt64ConfigValue(ctx context.Context, key string) (int64, error) {
value, err := c.getConfigValue(ctx, key)
if err != nil {
return 0, err
}
var result int64
if value != nil {
result, err = strconv.ParseInt(string(value), 10, 64)
if err != nil {
return 0, err
}
}
return result, nil
}
func (c *ClICtrl) getConfigValue(ctx context.Context, key string) ([]byte, error) {
var value []byte
err := c.DB.View(func(tx *bolt.Tx) error {
kvBucket, err := getAccountStore(ctx, tx, model.KVConfig)
if err != nil {
return err
}
value = kvBucket.Get([]byte(key))
return nil
})
return value, err
}
func (c *ClICtrl) GetAllValues(ctx context.Context, store model.PhotosStore) ([][]byte, error) {
result := make([][]byte, 0)
err := c.DB.View(func(tx *bolt.Tx) error {
kvBucket, err := getAccountStore(ctx, tx, store)
if err != nil {
return err
}
kvBucket.ForEach(func(k, v []byte) error {
result = append(result, v)
return nil
})
return nil
})
return result, err
}
func (c *ClICtrl) PutConfigValue(ctx context.Context, key string, value []byte) error {
return c.DB.Update(func(tx *bolt.Tx) error {
kvBucket, err := getAccountStore(ctx, tx, model.KVConfig)
if err != nil {
return err
}
return kvBucket.Put([]byte(key), value)
})
}
func (c *ClICtrl) PutValue(ctx context.Context, store model.PhotosStore, key []byte, value []byte) error {
return c.DB.Update(func(tx *bolt.Tx) error {
kvBucket, err := getAccountStore(ctx, tx, store)
if err != nil {
return err
}
return kvBucket.Put(key, value)
})
}
func (c *ClICtrl) DeleteValue(ctx context.Context, store model.PhotosStore, key []byte) error {
return c.DB.Update(func(tx *bolt.Tx) error {
kvBucket, err := getAccountStore(ctx, tx, store)
if err != nil {
return err
}
return kvBucket.Delete(key)
})
}
// GetValue
func (c *ClICtrl) GetValue(ctx context.Context, store model.PhotosStore, key []byte) ([]byte, error) {
var value []byte
err := c.DB.View(func(tx *bolt.Tx) error {
kvBucket, err := getAccountStore(ctx, tx, store)
if err != nil {
return err
}
value = kvBucket.Get(key)
return nil
})
return value, err
}
func getAccountStore(ctx context.Context, tx *bolt.Tx, storeType model.PhotosStore) (*bolt.Bucket, error) {
accountKey := ctx.Value("account_key").(string)
accountBucket := tx.Bucket([]byte(accountKey))
if accountBucket == nil {
return nil, fmt.Errorf("account bucket not found")
}
store := accountBucket.Bucket([]byte(storeType))
if store == nil {
return nil, fmt.Errorf("store %s not found", storeType)
}
return store, nil
}

118
cli/pkg/sync.go Normal file
View file

@ -0,0 +1,118 @@
package pkg
import (
"context"
"encoding/base64"
"fmt"
"github.com/ente-io/cli/internal"
"github.com/ente-io/cli/internal/api"
"github.com/ente-io/cli/pkg/model"
bolt "go.etcd.io/bbolt"
"log"
"time"
)
func (c *ClICtrl) Export() error {
accounts, err := c.GetAccounts(context.Background())
if err != nil {
return err
}
if len(accounts) == 0 {
fmt.Printf("No accounts to sync\n Add account using `account add` cmd\n")
return nil
}
for _, account := range accounts {
log.SetPrefix(fmt.Sprintf("[%s-%s] ", account.App, account.Email))
if account.ExportDir == "" {
log.Printf("Skip account %s: no export directory configured", account.Email)
continue
}
_, err = internal.ValidateDirForWrite(account.ExportDir)
if err != nil {
log.Printf("Skip export, error: %v while validing exportDir %s\n", err, account.ExportDir)
continue
}
if account.App == api.AppAuth {
log.Printf("Skip account %s: auth export is not supported", account.Email)
continue
}
log.Println("start sync")
retryCount := 0
for {
err = c.SyncAccount(account)
if err != nil {
if model.ShouldRetrySync(err) && retryCount < 20 {
retryCount = retryCount + 1
timeInSecond := time.Duration(retryCount*10) * time.Second
log.Printf("Connection err, waiting for %s before trying again", timeInSecond.String())
time.Sleep(timeInSecond)
continue
}
fmt.Printf("Error syncing account %s: %s\n", account.Email, err)
return err
} else {
log.Println("sync done")
break
}
}
}
return nil
}
func (c *ClICtrl) SyncAccount(account model.Account) error {
secretInfo, err := c.KeyHolder.LoadSecrets(account)
if err != nil {
return err
}
ctx := c.buildRequestContext(context.Background(), account)
err = createDataBuckets(c.DB, account)
if err != nil {
return err
}
c.Client.AddToken(account.AccountKey(), base64.URLEncoding.EncodeToString(secretInfo.Token))
err = c.fetchRemoteCollections(ctx)
if err != nil {
log.Printf("Error fetching collections: %s", err)
return err
}
err = c.fetchRemoteFiles(ctx)
if err != nil {
log.Printf("Error fetching files: %s", err)
return err
}
err = c.createLocalFolderForRemoteAlbums(ctx, account)
if err != nil {
log.Printf("Error creating local folders: %s", err)
return err
}
err = c.syncFiles(ctx, account)
if err != nil {
log.Printf("Error syncing files: %s", err)
return err
}
return nil
}
func (c *ClICtrl) buildRequestContext(ctx context.Context, account model.Account) context.Context {
ctx = context.WithValue(ctx, "app", string(account.App))
ctx = context.WithValue(ctx, "account_key", account.AccountKey())
ctx = context.WithValue(ctx, "user_id", account.UserID)
return ctx
}
func createDataBuckets(db *bolt.DB, account model.Account) error {
return db.Update(func(tx *bolt.Tx) error {
dataBucket, err := tx.CreateBucketIfNotExists([]byte(account.AccountKey()))
if err != nil {
return fmt.Errorf("create bucket: %s", err)
}
for _, subBucket := range []model.PhotosStore{model.KVConfig, model.RemoteAlbums, model.RemoteFiles, model.RemoteAlbumEntries} {
_, err := dataBucket.CreateBucketIfNotExists([]byte(subBucket))
if err != nil {
return err
}
}
return nil
})
}

43
cli/release.sh Executable file
View file

@ -0,0 +1,43 @@
#!/bin/bash
# Create a "bin" directory if it doesn't exist
mkdir -p bin
# List of target operating systems
OS_TARGETS=("windows" "linux" "darwin")
# Corresponding architectures for each OS
ARCH_TARGETS=("386 amd64" "386 amd64 arm arm64" "amd64 arm64")
# Loop through each OS target
for index in "${!OS_TARGETS[@]}"
do
OS=${OS_TARGETS[$index]}
for ARCH in ${ARCH_TARGETS[$index]}
do
# Set the GOOS environment variable for the current target OS
export GOOS="$OS"
export GOARCH="$ARCH"
# Set the output binary name to "ente-cli" for the current OS and architecture
BINARY_NAME="ente-$OS-$ARCH"
# Add .exe extension for Windows
if [ "$OS" == "windows" ]; then
BINARY_NAME="ente-$OS-$ARCH.exe"
fi
# Build the binary and place it in the "bin" directory
go build -o "bin/$BINARY_NAME" main.go
# Print a message indicating the build is complete for the current OS and architecture
echo "Built for $OS ($ARCH) as bin/$BINARY_NAME"
done
done
# Clean up any environment variables
unset GOOS
unset GOARCH
# Print a message indicating the build process is complete
echo "Build process completed for all platforms and architectures. Binaries are in the 'bin' directory."

View file

@ -0,0 +1,3 @@
package constants
const CliDataPath = "/cli-data/"

View file

@ -0,0 +1,26 @@
package encoding
import (
"encoding/base64"
"encoding/json"
)
func DecodeBase64(s string) []byte {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
panic(err)
}
return b
}
func EncodeBase64(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}
func MustMarshalJSON(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}

25
cli/utils/time.go Normal file
View file

@ -0,0 +1,25 @@
package utils
import (
"fmt"
"log"
"time"
)
func TimeTrack(start time.Time, name string) {
elapsed := time.Since(start)
log.Printf("%s took %s", name, elapsed)
}
func ByteCountDecimal(b int64) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp])
}