├── .github └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── Dockerfile-x86 ├── README.md ├── cmd ├── LICENSE ├── account.go ├── config.go ├── export.go ├── root.go └── version.go ├── config.yaml ├── docker-compose.yml ├── go.mod ├── go.sum ├── internal ├── api │ ├── api_error.go │ ├── client.go │ ├── collection.go │ ├── collection_type.go │ ├── enums.go │ ├── file_type.go │ ├── files.go │ ├── log.go │ ├── login.go │ └── login_type.go ├── crypto │ ├── crypto.go │ ├── crypto_libsodium.go │ ├── crypto_test.go │ ├── stream.go │ └── utils.go └── promt.go ├── main.go ├── pkg ├── account.go ├── bolt_store.go ├── cli.go ├── disk.go ├── disk_test.go ├── download.go ├── mapper │ └── photo.go ├── model │ ├── account.go │ ├── constants.go │ ├── enc_string.go │ ├── enc_string_test.go │ ├── errors.go │ ├── export │ │ ├── location.go │ │ └── metadata.go │ └── remote.go ├── remote_sync.go ├── remote_to_disk_album.go ├── remote_to_disk_file.go ├── secrets │ ├── key_holder.go │ └── secret.go ├── sign_in.go ├── store.go └── sync.go ├── release.sh └── utils ├── constants └── constants.go ├── encoding └── encoding.go └── time.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | # allow manual run 5 | push: 6 | tags: 7 | - 'v*.*.*' # This will run the workflow when you push a new tag in the format v0.0.0 8 | - 'v*.*.*-beta.*' 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Install latest Syft 15 | run: | 16 | 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 17 | sudo rpm -i syft_latest_linux_amd64.rpm 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 # Important to ensure that GoReleaser works correctly 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v4 26 | with: 27 | go-version: '1.20' # You can adjust the Go version here 28 | 29 | - name: Run GoReleaser 30 | uses: goreleaser/goreleaser-action@v5 31 | with: 32 | distribution: goreleaser 33 | version: latest 34 | args: release --rm-dist 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Use the provided GITHUB_TOKEN secret 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/** 2 | .DS_Store 3 | Photos.code-workspace 4 | logs/** 5 | .idea/** 6 | .vscode/** 7 | tmp/** 8 | scratch/** 9 | main 10 | config.yaml 11 | ente-cli.db 12 | bin/** 13 | dist/ 14 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines bellow are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | project_name: ente 9 | before: 10 | hooks: 11 | # You may remove this if you don't use go modules. 12 | - go mod tidy 13 | # you may remove this if you don't need go generate 14 | - go generate ./... 15 | 16 | builds: 17 | - env: 18 | - CGO_ENABLED=0 19 | goos: 20 | - linux 21 | - windows 22 | - darwin 23 | 24 | nfpms: 25 | - package_name: ente 26 | homepage: https://github.com/ente-io/cli 27 | maintainer: ente.io 28 | description: |- 29 | Command Line Utility for exporting data from https://ente.io 30 | formats: 31 | - rpm 32 | - deb 33 | - apk 34 | 35 | sboms: 36 | - artifacts: archive 37 | 38 | archives: 39 | - format: tar.gz 40 | # this name template makes the OS and Arch compatible with the results of `uname`. 41 | name_template: >- 42 | {{ .ProjectName }}_ 43 | {{- title .Os }}_ 44 | {{- if eq .Arch "amd64" }}x86_64 45 | {{- else if eq .Arch "386" }}i386 46 | {{- else }}{{ .Arch }}{{ end }} 47 | {{- if .Arm }}v{{ .Arm }}{{ end }} 48 | # use zip for windows archives 49 | format_overrides: 50 | - goos: windows 51 | format: zip 52 | 53 | changelog: 54 | sort: asc 55 | filters: 56 | exclude: 57 | - "^docs:" 58 | - "^test:" 59 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.20-alpine3.17 as builder 2 | RUN apk add --no-cache gcc musl-dev git build-base pkgconfig libsodium-dev 3 | 4 | ENV GOOS=linux 5 | 6 | WORKDIR /etc/ente/ 7 | 8 | COPY go.mod . 9 | COPY go.sum . 10 | RUN go mod download 11 | 12 | COPY . . 13 | # the --mount option requires BuildKit. Refer to https://docs.docker.com/go/buildkit/ to learn how to build images with BuildKit enabled 14 | RUN --mount=type=cache,target=/root/.cache/go-build \ 15 | go build -o ente-cli main.go 16 | 17 | FROM alpine:3.17 18 | RUN apk add libsodium-dev 19 | COPY --from=builder /etc/ente/ente-cli . 20 | 21 | ARG GIT_COMMIT 22 | ENV GIT_COMMIT=$GIT_COMMIT 23 | 24 | CMD ["./ente-cli"] 25 | -------------------------------------------------------------------------------- /Dockerfile-x86: -------------------------------------------------------------------------------- 1 | FROM golang:1.20-alpine3.17@sha256:9c2f89db6fda13c3c480749787f62fed5831699bb2c32881b8f327f1cf7bae42 as builder386 2 | RUN apt-get update 3 | RUN apt-get install -y gcc 4 | RUN apt-get install -y git 5 | RUN apt-get install -y pkg-config 6 | RUN apt-get install -y libsodium-dev 7 | 8 | 9 | ENV GOOS=linux 10 | 11 | WORKDIR /etc/ente/ 12 | RUN uname -a 13 | COPY go.mod . 14 | COPY go.sum . 15 | RUN go mod download 16 | 17 | 18 | COPY . . 19 | RUN --mount=type=cache,target=/root/.cache/go-build \ 20 | go build -o ente-cli main.go 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notice - This code has moved to https://github.com/ente-io/ente 2 | 3 | We've consolidated our code into a single repository as part of open sourcing 4 | our server. You can read more about it 5 | [here](https://ente.io/blog/open-sourcing-our-server/). 6 | 7 | **Download new versions from [ente-io/ente](https://github.com/ente-io/ente/releases?q=cli&expanded=true)** 8 | 9 | Please also use the [new repository](https://github.com/ente-io/ente) if you 10 | wish to open new issues and feature requests. 11 | -------------------------------------------------------------------------------- /cmd/LICENSE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ente-io/cli/16837fa51d23585f3a405c2454de8d98bc76e09c/cmd/LICENSE -------------------------------------------------------------------------------- /cmd/account.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/ente-io/cli/internal/api" 7 | "github.com/ente-io/cli/pkg/model" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // Define the 'account' command and its subcommands 12 | var accountCmd = &cobra.Command{ 13 | Use: "account", 14 | Short: "Manage account settings", 15 | } 16 | 17 | // Subcommand for 'account list' 18 | var listAccCmd = &cobra.Command{ 19 | Use: "list", 20 | Short: "list configured accounts", 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | recoverWithLog() 23 | return ctrl.ListAccounts(context.Background()) 24 | }, 25 | } 26 | 27 | // Subcommand for 'account add' 28 | var addAccCmd = &cobra.Command{ 29 | Use: "add", 30 | Short: "Add a new account", 31 | Run: func(cmd *cobra.Command, args []string) { 32 | recoverWithLog() 33 | ctrl.AddAccount(context.Background()) 34 | }, 35 | } 36 | 37 | // Subcommand for 'account update' 38 | var updateAccCmd = &cobra.Command{ 39 | Use: "update", 40 | Short: "Update an existing account's export directory", 41 | Run: func(cmd *cobra.Command, args []string) { 42 | recoverWithLog() 43 | exportDir, _ := cmd.Flags().GetString("dir") 44 | app, _ := cmd.Flags().GetString("app") 45 | email, _ := cmd.Flags().GetString("email") 46 | if email == "" { 47 | fmt.Println("email must be specified") 48 | return 49 | } 50 | if exportDir == "" { 51 | fmt.Println("dir param must be specified") 52 | return 53 | } 54 | 55 | validApps := map[string]bool{ 56 | "photos": true, 57 | "locker": true, 58 | "auth": true, 59 | } 60 | 61 | if !validApps[app] { 62 | fmt.Printf("invalid app. Accepted values are 'photos', 'locker', 'auth'") 63 | 64 | } 65 | err := ctrl.UpdateAccount(context.Background(), model.UpdateAccountParams{ 66 | Email: email, 67 | App: api.StringToApp(app), 68 | ExportDir: &exportDir, 69 | }) 70 | if err != nil { 71 | fmt.Printf("Error updating account: %v\n", err) 72 | } 73 | }, 74 | } 75 | 76 | func init() { 77 | // Add 'config' subcommands to the root command 78 | rootCmd.AddCommand(accountCmd) 79 | // Add 'config' subcommands to the 'config' command 80 | updateAccCmd.Flags().String("dir", "", "update export directory") 81 | updateAccCmd.Flags().String("email", "", "email address of the account to update") 82 | updateAccCmd.Flags().String("app", "photos", "Specify the app, default is 'photos'") 83 | accountCmd.AddCommand(listAccCmd, addAccCmd, updateAccCmd) 84 | } 85 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | // Define the 'config' command and its subcommands 10 | var configCmd = &cobra.Command{ 11 | Use: "config", 12 | Short: "Manage configuration settings", 13 | } 14 | 15 | // Subcommand for 'config show' 16 | var showCmd = &cobra.Command{ 17 | Use: "show", 18 | Short: "Show configuration settings", 19 | Run: func(cmd *cobra.Command, args []string) { 20 | fmt.Println("host:", viper.GetString("host")) 21 | }, 22 | } 23 | 24 | // Subcommand for 'config update' 25 | var updateCmd = &cobra.Command{ 26 | Use: "update", 27 | Short: "Update a configuration setting", 28 | Run: func(cmd *cobra.Command, args []string) { 29 | viper.Set("host", host) 30 | err := viper.WriteConfig() 31 | if err != nil { 32 | fmt.Println("Error updating 'host' configuration:", err) 33 | return 34 | } 35 | fmt.Println("Updating 'host' configuration:", host) 36 | }, 37 | } 38 | 39 | // Flag to specify the 'host' configuration value 40 | var host string 41 | 42 | func init() { 43 | // Set up Viper configuration 44 | // Set a default value for 'host' configuration 45 | viper.SetDefault("host", "https://api.ente.io") 46 | 47 | // Add 'config' subcommands to the root command 48 | //rootCmd.AddCommand(configCmd) 49 | 50 | // Add flags to the 'config store' and 'config update' subcommands 51 | updateCmd.Flags().StringVarP(&host, "host", "H", viper.GetString("host"), "Update the 'host' configuration") 52 | // Mark 'host' flag as required for the 'update' command 53 | updateCmd.MarkFlagRequired("host") 54 | 55 | // Add 'config' subcommands to the 'config' command 56 | configCmd.AddCommand(showCmd, updateCmd) 57 | } 58 | -------------------------------------------------------------------------------- /cmd/export.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // versionCmd represents the version command 8 | var exportCmd = &cobra.Command{ 9 | Use: "export", 10 | Short: "Starts the export process", 11 | Long: ``, 12 | Run: func(cmd *cobra.Command, args []string) { 13 | ctrl.Export() 14 | }, 15 | } 16 | 17 | func init() { 18 | rootCmd.AddCommand(exportCmd) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ente-io/cli/pkg" 6 | "os" 7 | "runtime" 8 | 9 | "github.com/spf13/viper" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | const AppVersion = "0.1.10" 15 | 16 | var ctrl *pkg.ClICtrl 17 | 18 | // rootCmd represents the base command when called without any subcommands 19 | var rootCmd = &cobra.Command{ 20 | Use: "ente", 21 | Short: "CLI tool for exporting your photos from ente.io", 22 | Long: `Start by creating a config file in your home directory:`, 23 | // Uncomment the following line if your bare application 24 | // has an action associated with it: 25 | Run: func(cmd *cobra.Command, args []string) { 26 | fmt.Sprintf("Hello World") 27 | }, 28 | } 29 | 30 | // Execute adds all child commands to the root command and sets flags appropriately. 31 | // This is called by main.main(). It only needs to happen once to the rootCmd. 32 | func Execute(controller *pkg.ClICtrl) { 33 | ctrl = controller 34 | err := rootCmd.Execute() 35 | if err != nil { 36 | os.Exit(1) 37 | } 38 | } 39 | 40 | func init() { 41 | // Here you will define your flags and configuration settings. 42 | // Cobra supports persistent flags, which, if defined here, 43 | // will be global for your application. 44 | 45 | // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cli-go.yaml)") 46 | 47 | // Cobra also supports local flags, which will only run 48 | // when this action is called directly. 49 | rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 50 | viper.SetConfigName("config") // Name of your configuration file (e.g., config.yaml) 51 | viper.AddConfigPath(".") // Search for config file in the current directory 52 | viper.ReadInConfig() // Read the configuration file if it exists 53 | } 54 | 55 | func recoverWithLog() { 56 | if r := recover(); r != nil { 57 | fmt.Println("Panic occurred:", r) 58 | // Print the stack trace 59 | stackTrace := make([]byte, 1024*8) 60 | stackTrace = stackTrace[:runtime.Stack(stackTrace, false)] 61 | fmt.Printf("Stack Trace:\n%s", stackTrace) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // versionCmd represents the version command 10 | var versionCmd = &cobra.Command{ 11 | Use: "version", 12 | Short: "Prints the current version", 13 | Long: ``, 14 | Run: func(cmd *cobra.Command, args []string) { 15 | fmt.Printf("Version %s\n", AppVersion) 16 | }, 17 | } 18 | 19 | func init() { 20 | rootCmd.AddCommand(versionCmd) 21 | } 22 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ente-io/cli/16837fa51d23585f3a405c2454de8d98bc76e09c/config.yaml -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | ente-cli: 4 | image: ente-cli:latest 5 | command: /bin/sh 6 | volumes: 7 | # Replace /Volumes/Data/ with a folder path on your system, typically $HOME/.ente-cli/ 8 | - ~/.ente-cli/:/cli-data:rw 9 | # - ~/Downloads/export-data:/data:rw 10 | stdin_open: true 11 | tty: true 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ente-io/cli 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/go-resty/resty/v2 v2.7.0 7 | github.com/google/uuid v1.3.1 8 | github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1 9 | github.com/zalando/go-keyring v0.2.3 10 | golang.org/x/crypto v0.14.0 11 | ) 12 | 13 | require ( 14 | github.com/alessio/shellescape v1.4.1 // indirect 15 | github.com/danieljoos/wincred v1.2.0 // indirect 16 | github.com/godbus/dbus/v5 v5.1.0 // indirect 17 | github.com/mattn/go-colorable v0.1.13 // indirect 18 | github.com/mattn/go-isatty v0.0.17 // indirect 19 | ) 20 | 21 | require ( 22 | github.com/fatih/color v1.15.0 23 | github.com/fsnotify/fsnotify v1.6.0 // indirect 24 | github.com/hashicorp/hcl v1.0.0 // indirect 25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 26 | github.com/kong/go-srp v0.0.0-20191210190804-cde1efa3c083 27 | github.com/magiconair/properties v1.8.7 // indirect 28 | github.com/mitchellh/mapstructure v1.5.0 // indirect 29 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 30 | github.com/spf13/afero v1.9.5 // indirect 31 | github.com/spf13/cast v1.5.1 // indirect 32 | github.com/spf13/cobra v1.7.0 33 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 34 | github.com/spf13/pflag v1.0.5 // indirect 35 | github.com/spf13/viper v1.16.0 36 | github.com/subosito/gotenv v1.6.0 // indirect 37 | go.etcd.io/bbolt v1.3.7 38 | golang.org/x/net v0.10.0 // indirect 39 | golang.org/x/sys v0.13.0 // indirect 40 | golang.org/x/term v0.13.0 41 | golang.org/x/text v0.13.0 // indirect 42 | gopkg.in/ini.v1 v1.67.0 // indirect 43 | gopkg.in/yaml.v3 v3.0.1 // indirect 44 | ) 45 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 4 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= 5 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 6 | cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 7 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 8 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 9 | cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= 10 | cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= 11 | cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= 12 | cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= 13 | cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= 14 | cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= 15 | cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= 16 | cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= 17 | cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= 18 | cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= 19 | cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= 20 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 21 | cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= 22 | cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= 23 | cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= 24 | cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= 25 | cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= 26 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 27 | cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= 28 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 29 | cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= 30 | cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= 31 | cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= 32 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 33 | cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= 34 | cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= 35 | cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= 36 | cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= 37 | cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= 38 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 39 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 40 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 41 | github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= 42 | github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= 43 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 44 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 45 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 46 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 47 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 48 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 49 | github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= 50 | github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= 51 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 52 | github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= 53 | github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= 54 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 55 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 56 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 57 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 58 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 59 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 60 | github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= 61 | github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= 62 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 63 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 64 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 65 | github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= 66 | github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 67 | github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 68 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 69 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 70 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= 71 | github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= 72 | github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= 73 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 74 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 75 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 76 | github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 77 | github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 78 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 79 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 80 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 81 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 82 | github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 83 | github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 84 | github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 85 | github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 86 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 87 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 88 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 89 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 90 | github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 91 | github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 92 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 93 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 94 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 95 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 96 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 97 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 98 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 99 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 100 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 101 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 102 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 103 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 104 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 105 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 106 | github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 107 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 108 | github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 109 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 110 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 111 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 112 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 113 | github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= 114 | github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= 115 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 116 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 117 | github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 118 | github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 119 | github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 120 | github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 121 | github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= 122 | github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 123 | github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 124 | github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 125 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 126 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 127 | github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= 128 | github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 129 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 130 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 131 | github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= 132 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 133 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 134 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 135 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 136 | github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 137 | github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 138 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 139 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 140 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 141 | github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= 142 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 143 | github.com/kong/go-srp v0.0.0-20191210190804-cde1efa3c083 h1:Y7nibF/3Ivmk+S4Q+KzVv98lFlSdrBhYzG44d5il85E= 144 | github.com/kong/go-srp v0.0.0-20191210190804-cde1efa3c083/go.mod h1:Zde5RRLiH8/2zEXQDHX5W0dOOTxkemzrXMhHVfxTtTA= 145 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 146 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 147 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 148 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 149 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 150 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 151 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 152 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 153 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 154 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 155 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 156 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 157 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 158 | github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1 h1:lYpkrQH5ajf0OXOcUbGjvZxxijuBwbbmlSxLiuofa+g= 159 | github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= 160 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 161 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 162 | github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= 163 | github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 164 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 165 | github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= 166 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 167 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 168 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 169 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 170 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 171 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 172 | github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= 173 | github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= 174 | github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= 175 | github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= 176 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 177 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 178 | github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= 179 | github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= 180 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 181 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 182 | github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= 183 | github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= 184 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 185 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 186 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 187 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 188 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 189 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 190 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 191 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 192 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 193 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 194 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 195 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 196 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 197 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 198 | github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 199 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 200 | github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 201 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 202 | github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= 203 | github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= 204 | go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= 205 | go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= 206 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 207 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 208 | go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 209 | go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 210 | go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 211 | go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= 212 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 213 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 214 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 215 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 216 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 217 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 218 | golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 219 | golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= 220 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 221 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 222 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 223 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 224 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 225 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 226 | golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 227 | golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 228 | golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 229 | golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= 230 | golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= 231 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 232 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 233 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 234 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 235 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 236 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 237 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 238 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 239 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 240 | golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= 241 | golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 242 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 243 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 244 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 245 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 246 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 247 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 248 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 249 | golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 250 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 251 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 252 | golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 253 | golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 254 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 255 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 256 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 257 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 258 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 259 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 260 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 261 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 262 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 263 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 264 | golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 265 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 266 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 267 | golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 268 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 269 | golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 270 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 271 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 272 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 273 | golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 274 | golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 275 | golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 276 | golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 277 | golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 278 | golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 279 | golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 280 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 281 | golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 282 | golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 283 | golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 284 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 285 | golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 286 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 287 | golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= 288 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 289 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 290 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 291 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 292 | golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 293 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 294 | golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 295 | golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 296 | golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 297 | golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 298 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 299 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 300 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 301 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 302 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 303 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 304 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 305 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 306 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 307 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 308 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 309 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 310 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 311 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 312 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 313 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 314 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 315 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 316 | golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 317 | golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 318 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 319 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 320 | golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 321 | golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 322 | golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 323 | golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 324 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 325 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 326 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 327 | golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 328 | golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 329 | golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 330 | golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 331 | golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 332 | golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 333 | golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 334 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 335 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 336 | golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 337 | golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 338 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 339 | golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 340 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 341 | golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 342 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 343 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 344 | golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 345 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 346 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 347 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 348 | golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= 349 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 350 | golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 351 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 352 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 353 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 354 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 355 | golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 356 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 357 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 358 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 359 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 360 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 361 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 362 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 363 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 364 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 365 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 366 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 367 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 368 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 369 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 370 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 371 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 372 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 373 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 374 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 375 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 376 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 377 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 378 | golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 379 | golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 380 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 381 | golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 382 | golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 383 | golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 384 | golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 385 | golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 386 | golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 387 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 388 | golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 389 | golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 390 | golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 391 | golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 392 | golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 393 | golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 394 | golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 395 | golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= 396 | golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 397 | golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 398 | golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 399 | golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 400 | golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 401 | golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 402 | golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 403 | golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= 404 | golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 405 | golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 406 | golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 407 | golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 408 | golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 409 | golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 410 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 411 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 412 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 413 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 414 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 415 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 416 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 417 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 418 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 419 | google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 420 | google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 421 | google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 422 | google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 423 | google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 424 | google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 425 | google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= 426 | google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 427 | google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= 428 | google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= 429 | google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= 430 | google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= 431 | google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= 432 | google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= 433 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 434 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 435 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 436 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 437 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 438 | google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 439 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 440 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 441 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 442 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 443 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 444 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 445 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 446 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 447 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 448 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 449 | google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 450 | google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 451 | google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 452 | google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 453 | google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 454 | google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= 455 | google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 456 | google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 457 | google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 458 | google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 459 | google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 460 | google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 461 | google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 462 | google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 463 | google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= 464 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 465 | google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= 466 | google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 467 | google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 468 | google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 469 | google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 470 | google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 471 | google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 472 | google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 473 | google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 474 | google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 475 | google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= 476 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 477 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 478 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 479 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 480 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 481 | google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 482 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 483 | google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 484 | google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= 485 | google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= 486 | google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 487 | google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 488 | google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 489 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 490 | google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= 491 | google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= 492 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 493 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 494 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 495 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 496 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 497 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 498 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 499 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 500 | google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= 501 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 502 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 503 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 504 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 505 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 506 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 507 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 508 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 509 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 510 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 511 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 512 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 513 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 514 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 515 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 516 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 517 | honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 518 | honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= 519 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 520 | rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= 521 | rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= 522 | -------------------------------------------------------------------------------- /internal/api/api_error.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type ApiError struct { 9 | Message string 10 | StatusCode int 11 | } 12 | 13 | func (e *ApiError) Error() string { 14 | return fmt.Sprintf("status %d with err: %s", e.StatusCode, e.Message) 15 | } 16 | 17 | func IsApiError(err error) bool { 18 | _, ok := err.(*ApiError) 19 | return ok 20 | } 21 | 22 | func IsFileNotInAlbumError(err error) bool { 23 | if apiErr, ok := err.(*ApiError); ok { 24 | return strings.Contains(apiErr.Message, "FILE_NOT_FOUND_IN_ALBUM") 25 | } 26 | return false 27 | } 28 | -------------------------------------------------------------------------------- /internal/api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "github.com/go-resty/resty/v2" 6 | "log" 7 | "time" 8 | ) 9 | 10 | const ( 11 | EnteAPIEndpoint = "https://api.ente.io" 12 | TokenHeader = "X-Auth-Token" 13 | TokenQuery = "token" 14 | ClientPkgHeader = "X-Client-Package" 15 | ) 16 | 17 | var ( 18 | RedactedHeaders = []string{TokenHeader, " X-Request-Id"} 19 | ) 20 | var tokenMap map[string]string = make(map[string]string) 21 | 22 | type Client struct { 23 | restClient *resty.Client 24 | // use separate client for downloading files 25 | downloadClient *resty.Client 26 | } 27 | 28 | type Params struct { 29 | Debug bool 30 | Trace bool 31 | Host string 32 | } 33 | 34 | func readValueFromContext(ctx context.Context, key string) interface{} { 35 | value := ctx.Value(key) 36 | return value 37 | } 38 | 39 | func NewClient(p Params) *Client { 40 | enteAPI := resty.New() 41 | 42 | if p.Trace { 43 | enteAPI.EnableTrace() 44 | } 45 | enteAPI.OnBeforeRequest(func(c *resty.Client, req *resty.Request) error { 46 | app := readValueFromContext(req.Context(), "app") 47 | if app == nil { 48 | panic("app not set in context") 49 | } 50 | req.Header.Set(ClientPkgHeader, StringToApp(app.(string)).ClientPkg()) 51 | attachToken(req) 52 | return nil 53 | }) 54 | if p.Debug { 55 | enteAPI.OnBeforeRequest(func(c *resty.Client, req *resty.Request) error { 56 | logRequest(req) 57 | return nil 58 | }) 59 | 60 | enteAPI.OnAfterResponse(func(c *resty.Client, resp *resty.Response) error { 61 | logResponse(resp) 62 | return nil 63 | }) 64 | } 65 | if p.Host != "" { 66 | enteAPI.SetBaseURL(p.Host) 67 | } else { 68 | enteAPI.SetBaseURL(EnteAPIEndpoint) 69 | } 70 | return &Client{ 71 | restClient: enteAPI, 72 | downloadClient: resty.New(). 73 | SetRetryCount(3). 74 | SetRetryWaitTime(5 * time.Second). 75 | SetRetryMaxWaitTime(10 * time.Second). 76 | AddRetryCondition(func(r *resty.Response, err error) bool { 77 | shouldRetry := r.StatusCode() == 429 || r.StatusCode() > 500 78 | if shouldRetry { 79 | log.Printf("retrying download due to %d code", r.StatusCode()) 80 | } 81 | return shouldRetry 82 | }), 83 | } 84 | } 85 | 86 | func attachToken(req *resty.Request) { 87 | accountKey := readValueFromContext(req.Context(), "account_key") 88 | if accountKey != nil && accountKey != "" { 89 | if token, ok := tokenMap[accountKey.(string)]; ok { 90 | req.SetHeader(TokenHeader, token) 91 | } 92 | } 93 | } 94 | 95 | func (c *Client) AddToken(id string, token string) { 96 | tokenMap[id] = token 97 | } 98 | -------------------------------------------------------------------------------- /internal/api/collection.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | ) 7 | 8 | func (c *Client) GetCollections(ctx context.Context, sinceTime int64) ([]Collection, error) { 9 | var res struct { 10 | Collections []Collection `json:"collections"` 11 | } 12 | r, err := c.restClient.R(). 13 | SetContext(ctx). 14 | SetQueryParam("sinceTime", strconv.FormatInt(sinceTime, 10)). 15 | SetResult(&res). 16 | Get("/collections/v2") 17 | if r.IsError() { 18 | return nil, &ApiError{ 19 | StatusCode: r.StatusCode(), 20 | Message: r.String(), 21 | } 22 | } 23 | return res.Collections, err 24 | } 25 | 26 | func (c *Client) GetFiles(ctx context.Context, collectionID, sinceTime int64) ([]File, bool, error) { 27 | var res struct { 28 | Files []File `json:"diff"` 29 | HasMore bool `json:"hasMore"` 30 | } 31 | r, err := c.restClient.R(). 32 | SetContext(ctx). 33 | SetQueryParam("sinceTime", strconv.FormatInt(sinceTime, 10)). 34 | SetQueryParam("collectionID", strconv.FormatInt(collectionID, 10)). 35 | SetResult(&res). 36 | Get("/collections/v2/diff") 37 | if r.IsError() { 38 | return nil, false, &ApiError{ 39 | StatusCode: r.StatusCode(), 40 | Message: r.String(), 41 | } 42 | } 43 | return res.Files, res.HasMore, err 44 | } 45 | 46 | // GetFile .. 47 | func (c *Client) GetFile(ctx context.Context, collectionID, fileID int64) (*File, error) { 48 | var res struct { 49 | File File `json:"file"` 50 | } 51 | r, err := c.restClient.R(). 52 | SetContext(ctx). 53 | SetQueryParam("collectionID", strconv.FormatInt(collectionID, 10)). 54 | SetQueryParam("fileID", strconv.FormatInt(fileID, 10)). 55 | SetResult(&res). 56 | Get("/collections/file") 57 | if r.IsError() { 58 | return nil, &ApiError{ 59 | StatusCode: r.StatusCode(), 60 | Message: r.String(), 61 | } 62 | } 63 | return &res.File, err 64 | } 65 | -------------------------------------------------------------------------------- /internal/api/collection_type.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | // Collection represents a collection 4 | type Collection struct { 5 | ID int64 `json:"id"` 6 | Owner CollectionUser `json:"owner"` 7 | EncryptedKey string `json:"encryptedKey" binding:"required"` 8 | KeyDecryptionNonce string `json:"keyDecryptionNonce,omitempty" binding:"required"` 9 | Name string `json:"name"` 10 | EncryptedName string `json:"encryptedName"` 11 | NameDecryptionNonce string `json:"nameDecryptionNonce"` 12 | Type string `json:"type" binding:"required"` 13 | Sharees []CollectionUser `json:"sharees"` 14 | UpdationTime int64 `json:"updationTime"` 15 | IsDeleted bool `json:"isDeleted,omitempty"` 16 | MagicMetadata *MagicMetadata `json:"magicMetadata,omitempty"` 17 | PublicMagicMetadata *MagicMetadata `json:"pubMagicMetadata,omitempty"` 18 | SharedMagicMetadata *MagicMetadata `json:"sharedMagicMetadata,omitempty"` 19 | collectionKey []byte 20 | } 21 | 22 | // CollectionUser represents the owner of a collection 23 | type CollectionUser struct { 24 | ID int64 `json:"id"` 25 | Email string `json:"email"` 26 | // Deprecated 27 | Name string `json:"name"` 28 | Role string `json:"role"` 29 | } 30 | 31 | type MagicMetadata struct { 32 | Version int `json:"version,omitempty" binding:"required"` 33 | Count int `json:"count,omitempty" binding:"required"` 34 | Data string `json:"data,omitempty" binding:"required"` 35 | Header string `json:"header,omitempty" binding:"required"` 36 | } 37 | 38 | // CollectionFileItem represents a file in an AddFilesRequest and MoveFilesRequest 39 | type CollectionFileItem struct { 40 | ID int64 `json:"id" binding:"required"` 41 | EncryptedKey string `json:"encryptedKey" binding:"required"` 42 | KeyDecryptionNonce string `json:"keyDecryptionNonce" binding:"required"` 43 | } 44 | -------------------------------------------------------------------------------- /internal/api/enums.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "fmt" 4 | 5 | type App string 6 | 7 | const ( 8 | AppPhotos App = "photos" 9 | AppAuth App = "auth" 10 | AppLocker App = "locker" 11 | ) 12 | 13 | func StringToApp(s string) App { 14 | switch s { 15 | case "photos": 16 | return AppPhotos 17 | case "auth": 18 | return AppAuth 19 | case "locker": 20 | return AppLocker 21 | default: 22 | panic(fmt.Sprintf("invalid app: %s", s)) 23 | } 24 | } 25 | func (a App) ClientPkg() string { 26 | switch a { 27 | case AppPhotos: 28 | return "io.ente.photos" 29 | case AppAuth: 30 | return "io.ente.auth" 31 | case AppLocker: 32 | return "io.ente.locker" 33 | } 34 | return "" 35 | } 36 | -------------------------------------------------------------------------------- /internal/api/file_type.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | // File represents an encrypted file in the system 4 | type File struct { 5 | ID int64 `json:"id"` 6 | OwnerID int64 `json:"ownerID"` 7 | CollectionID int64 `json:"collectionID"` 8 | CollectionOwnerID *int64 `json:"collectionOwnerID"` 9 | EncryptedKey string `json:"encryptedKey"` 10 | KeyDecryptionNonce string `json:"keyDecryptionNonce"` 11 | File FileAttributes `json:"file" binding:"required"` 12 | Thumbnail FileAttributes `json:"thumbnail" binding:"required"` 13 | Metadata FileAttributes `json:"metadata" binding:"required"` 14 | IsDeleted bool `json:"isDeleted"` 15 | UpdationTime int64 `json:"updationTime"` 16 | MagicMetadata *MagicMetadata `json:"magicMetadata,omitempty"` 17 | PubicMagicMetadata *MagicMetadata `json:"pubMagicMetadata,omitempty"` 18 | Info *FileInfo `json:"info,omitempty"` 19 | } 20 | 21 | // FileInfo has information about storage used by the file & it's metadata(future) 22 | type FileInfo struct { 23 | FileSize int64 `json:"fileSize,omitempty"` 24 | ThumbnailSize int64 `json:"thumbSize,omitempty"` 25 | } 26 | 27 | // FileAttributes represents a file item 28 | type FileAttributes struct { 29 | EncryptedData string `json:"encryptedData,omitempty"` 30 | DecryptionHeader string `json:"decryptionHeader" binding:"required"` 31 | } 32 | -------------------------------------------------------------------------------- /internal/api/files.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | ) 7 | 8 | var ( 9 | downloadHost = "https://files.ente.io/?fileID=" 10 | ) 11 | 12 | func (c *Client) DownloadFile(ctx context.Context, fileID int64, absolutePath string) error { 13 | req := c.downloadClient.R(). 14 | SetContext(ctx). 15 | SetOutput(absolutePath) 16 | attachToken(req) 17 | r, err := req.Get(downloadHost + strconv.FormatInt(fileID, 10)) 18 | if r.IsError() { 19 | return &ApiError{ 20 | StatusCode: r.StatusCode(), 21 | Message: r.String(), 22 | } 23 | } 24 | return err 25 | } 26 | -------------------------------------------------------------------------------- /internal/api/log.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/fatih/color" 8 | "github.com/go-resty/resty/v2" 9 | ) 10 | 11 | func logRequest(req *resty.Request) { 12 | fmt.Println(color.GreenString("Request:")) 13 | fmt.Printf("%s %s\n", color.CyanString(req.Method), color.YellowString(req.URL)) 14 | fmt.Println(color.GreenString("Headers:")) 15 | for k, v := range req.Header { 16 | redacted := false 17 | for _, rh := range RedactedHeaders { 18 | if strings.EqualFold(strings.ToLower(k), strings.ToLower(rh)) { 19 | redacted = true 20 | break 21 | } 22 | } 23 | if redacted { 24 | fmt.Printf("%s: %s\n", color.CyanString(k), color.RedString("REDACTED")) 25 | } else { 26 | if len(v) == 1 { 27 | fmt.Printf("%s: %s\n", color.CyanString(k), color.YellowString(v[0])) 28 | } else { 29 | fmt.Printf("%s: %s\n", color.CyanString(k), color.YellowString(strings.Join(v, ","))) 30 | } 31 | } 32 | } 33 | } 34 | 35 | func logResponse(resp *resty.Response) { 36 | fmt.Println(color.GreenString("Response:")) 37 | if resp.StatusCode() < 200 || resp.StatusCode() >= 300 { 38 | fmt.Printf("%s %s\n", color.CyanString(resp.Proto()), color.RedString(resp.Status())) 39 | } else { 40 | fmt.Printf("%s %s\n", color.CyanString(resp.Proto()), color.YellowString(resp.Status())) 41 | } 42 | fmt.Printf("Time Duration: %s\n", resp.Time()) 43 | fmt.Println(color.GreenString("Headers:")) 44 | for k, v := range resp.Header() { 45 | redacted := false 46 | for _, rh := range RedactedHeaders { 47 | if strings.EqualFold(strings.ToLower(k), strings.ToLower(rh)) { 48 | redacted = true 49 | break 50 | } 51 | } 52 | if redacted { 53 | fmt.Printf("%s: %s\n", color.CyanString(k), color.RedString("REDACTED")) 54 | } else { 55 | fmt.Printf("%s: %s\n", color.CyanString(k), color.YellowString(strings.Join(v, ","))) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/api/login.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | func (c *Client) GetSRPAttributes(ctx context.Context, email string) (*SRPAttributes, error) { 10 | var res struct { 11 | SRPAttributes *SRPAttributes `json:"attributes"` 12 | } 13 | r, err := c.restClient.R(). 14 | SetContext(ctx). 15 | SetResult(&res). 16 | SetQueryParam("email", email). 17 | Get("/users/srp/attributes") 18 | if err != nil { 19 | return nil, err 20 | } 21 | if r.IsError() { 22 | return nil, &ApiError{ 23 | StatusCode: r.StatusCode(), 24 | Message: r.String(), 25 | } 26 | } 27 | return res.SRPAttributes, err 28 | } 29 | 30 | func (c *Client) CreateSRPSession( 31 | ctx context.Context, 32 | srpUserID uuid.UUID, 33 | clientPub string, 34 | ) (*CreateSRPSessionResponse, error) { 35 | var res CreateSRPSessionResponse 36 | payload := map[string]interface{}{ 37 | "srpUserID": srpUserID.String(), 38 | "srpA": clientPub, 39 | } 40 | r, err := c.restClient.R(). 41 | SetContext(ctx). 42 | SetResult(&res). 43 | SetBody(payload). 44 | Post("/users/srp/create-session") 45 | if err != nil { 46 | return nil, err 47 | } 48 | if r.IsError() { 49 | return nil, &ApiError{ 50 | StatusCode: r.StatusCode(), 51 | Message: r.String(), 52 | } 53 | } 54 | return &res, nil 55 | } 56 | 57 | func (c *Client) VerifySRPSession( 58 | ctx context.Context, 59 | srpUserID uuid.UUID, 60 | sessionID uuid.UUID, 61 | clientM1 string, 62 | ) (*AuthorizationResponse, error) { 63 | var res AuthorizationResponse 64 | payload := map[string]interface{}{ 65 | "srpUserID": srpUserID.String(), 66 | "sessionID": sessionID.String(), 67 | "srpM1": clientM1, 68 | } 69 | r, err := c.restClient.R(). 70 | SetContext(ctx). 71 | SetResult(&res). 72 | SetBody(payload). 73 | Post("/users/srp/verify-session") 74 | if err != nil { 75 | return nil, err 76 | } 77 | if r.IsError() { 78 | return nil, &ApiError{ 79 | StatusCode: r.StatusCode(), 80 | Message: r.String(), 81 | } 82 | } 83 | return &res, nil 84 | } 85 | 86 | func (c *Client) SendEmailOTP( 87 | ctx context.Context, 88 | email string, 89 | ) error { 90 | var res AuthorizationResponse 91 | payload := map[string]interface{}{ 92 | "email": email, 93 | } 94 | r, err := c.restClient.R(). 95 | SetContext(ctx). 96 | SetResult(&res). 97 | SetBody(payload). 98 | Post("/users/ott") 99 | if err != nil { 100 | return err 101 | } 102 | if r.IsError() { 103 | return &ApiError{ 104 | StatusCode: r.StatusCode(), 105 | Message: r.String(), 106 | } 107 | } 108 | return nil 109 | } 110 | 111 | func (c *Client) VerifyEmail( 112 | ctx context.Context, 113 | email string, 114 | otp string, 115 | ) (*AuthorizationResponse, error) { 116 | var res AuthorizationResponse 117 | payload := map[string]interface{}{ 118 | "email": email, 119 | "ott": otp, 120 | } 121 | r, err := c.restClient.R(). 122 | SetContext(ctx). 123 | SetResult(&res). 124 | SetBody(payload). 125 | Post("/users/verify-email") 126 | if err != nil { 127 | return nil, err 128 | } 129 | if r.IsError() { 130 | return nil, &ApiError{ 131 | StatusCode: r.StatusCode(), 132 | Message: r.String(), 133 | } 134 | } 135 | return &res, nil 136 | } 137 | 138 | func (c *Client) VerifyTotp( 139 | ctx context.Context, 140 | sessionID string, 141 | otp string, 142 | ) (*AuthorizationResponse, error) { 143 | var res AuthorizationResponse 144 | payload := map[string]interface{}{ 145 | "sessionID": sessionID, 146 | "code": otp, 147 | } 148 | r, err := c.restClient.R(). 149 | SetContext(ctx). 150 | SetResult(&res). 151 | SetBody(payload). 152 | Post("/users/two-factor/verify") 153 | if err != nil { 154 | return nil, err 155 | } 156 | if r.IsError() { 157 | return nil, &ApiError{ 158 | StatusCode: r.StatusCode(), 159 | Message: r.String(), 160 | } 161 | } 162 | return &res, nil 163 | } 164 | -------------------------------------------------------------------------------- /internal/api/login_type.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | type SRPAttributes struct { 8 | SRPUserID uuid.UUID `json:"srpUserID" binding:"required"` 9 | SRPSalt string `json:"srpSalt" binding:"required"` 10 | MemLimit int `json:"memLimit" binding:"required"` 11 | OpsLimit int `json:"opsLimit" binding:"required"` 12 | KekSalt string `json:"kekSalt" binding:"required"` 13 | IsEmailMFAEnabled bool `json:"isEmailMFAEnabled" binding:"required"` 14 | } 15 | 16 | type CreateSRPSessionResponse struct { 17 | SessionID uuid.UUID `json:"sessionID" binding:"required"` 18 | SRPB string `json:"srpB" binding:"required"` 19 | } 20 | 21 | // KeyAttributes stores the key related attributes for a user 22 | type KeyAttributes struct { 23 | KEKSalt string `json:"kekSalt" binding:"required"` 24 | KEKHash string `json:"kekHash"` 25 | EncryptedKey string `json:"encryptedKey" binding:"required"` 26 | KeyDecryptionNonce string `json:"keyDecryptionNonce" binding:"required"` 27 | PublicKey string `json:"publicKey" binding:"required"` 28 | EncryptedSecretKey string `json:"encryptedSecretKey" binding:"required"` 29 | SecretKeyDecryptionNonce string `json:"secretKeyDecryptionNonce" binding:"required"` 30 | MemLimit int `json:"memLimit" binding:"required"` 31 | OpsLimit int `json:"opsLimit" binding:"required"` 32 | } 33 | 34 | type AuthorizationResponse struct { 35 | ID int64 `json:"id"` 36 | KeyAttributes *KeyAttributes `json:"keyAttributes,omitempty"` 37 | EncryptedToken string `json:"encryptedToken,omitempty"` 38 | Token string `json:"token,omitempty"` 39 | TwoFactorSessionID string `json:"twoFactorSessionID"` 40 | // SrpM2 is sent only if the user is logging via SRP 41 | // SrpM2 is the SRP M2 value aka the proof that the server has the verifier 42 | SrpM2 *string `json:"srpM2,omitempty"` 43 | } 44 | 45 | func (a *AuthorizationResponse) IsMFARequired() bool { 46 | return a.TwoFactorSessionID != "" 47 | } 48 | -------------------------------------------------------------------------------- /internal/crypto/crypto.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/binary" 6 | "errors" 7 | "fmt" 8 | "github.com/minio/blake2b-simd" 9 | "golang.org/x/crypto/argon2" 10 | ) 11 | 12 | const ( 13 | loginSubKeyLen = 32 14 | loginSubKeyId = 1 15 | loginSubKeyContext = "loginctx" 16 | 17 | decryptionBufferSize = 4 * 1024 * 1024 18 | ) 19 | const ( 20 | cryptoKDFBlake2bBytesMin = 16 21 | cryptoKDFBlake2bBytesMax = 64 22 | cryptoGenerichashBlake2bSaltBytes = 16 23 | cryptoGenerichashBlake2bPersonalBytes = 16 24 | BoxSealBytes = 48 // 32 for the ephemeral public key + 16 for the MAC 25 | ) 26 | 27 | var ( 28 | ErrOpenBox = errors.New("failed to open box") 29 | ErrSealedOpenBox = errors.New("failed to open sealed box") 30 | ) 31 | 32 | const () 33 | 34 | // DeriveArgonKey generates a 32-bit cryptographic key using the Argon2id algorithm. 35 | // Parameters: 36 | // - password: The plaintext password to be hashed. 37 | // - salt: The salt as a base64 encoded string. 38 | // - memLimit: The memory limit in bytes. 39 | // - opsLimit: The number of iterations. 40 | // 41 | // Returns: 42 | // - A byte slice representing the derived key. 43 | // - An error object, which is nil if no error occurs. 44 | func DeriveArgonKey(password, salt string, memLimit, opsLimit int) ([]byte, error) { 45 | if memLimit < 1024 || opsLimit < 1 { 46 | return nil, fmt.Errorf("invalid memory or operation limits") 47 | } 48 | 49 | // Decode salt from base64 50 | saltBytes, err := base64.StdEncoding.DecodeString(salt) 51 | if err != nil { 52 | return nil, fmt.Errorf("invalid salt: %v", err) 53 | } 54 | 55 | // Generate key using Argon2id 56 | // Note: We're assuming a fixed key length of 32 bytes and changing the threads 57 | key := argon2.IDKey([]byte(password), saltBytes, uint32(opsLimit), uint32(memLimit/1024), 1, 32) 58 | 59 | return key, nil 60 | } 61 | 62 | // DeriveLoginKey derives a login key from the given key encryption key. 63 | // This loginKey act as user provided password during SRP authentication. 64 | // Parameters: keyEncKey: This is the keyEncryptionKey that is derived from the user's password. 65 | func DeriveLoginKey(keyEncKey []byte) []byte { 66 | subKey, _ := deriveSubKey(keyEncKey, loginSubKeyContext, loginSubKeyId, loginSubKeyLen) 67 | // return the first 16 bytes of the derived key 68 | return subKey[:16] 69 | } 70 | 71 | func deriveSubKey(masterKey []byte, context string, subKeyID uint64, subKeyLength uint32) ([]byte, error) { 72 | if subKeyLength < cryptoKDFBlake2bBytesMin || subKeyLength > cryptoKDFBlake2bBytesMax { 73 | return nil, fmt.Errorf("subKeyLength out of bounds") 74 | } 75 | // Pad the context 76 | ctxPadded := make([]byte, cryptoGenerichashBlake2bPersonalBytes) 77 | copy(ctxPadded, []byte(context)) 78 | // Convert subKeyID to byte slice and pad 79 | salt := make([]byte, cryptoGenerichashBlake2bSaltBytes) 80 | binary.LittleEndian.PutUint64(salt, subKeyID) 81 | 82 | // Create a BLAKE2b configuration 83 | config := &blake2b.Config{ 84 | Size: uint8(subKeyLength), 85 | Key: masterKey, 86 | Salt: salt, 87 | Person: ctxPadded, 88 | } 89 | hasher, err := blake2b.New(config) 90 | if err != nil { 91 | return nil, err 92 | } 93 | hasher.Write(nil) // No data, just using key, salt, and personalization 94 | return hasher.Sum(nil), nil 95 | } 96 | 97 | func DecryptChaChaBase64(data string, key []byte, nonce string) (string, []byte, error) { 98 | // Decode data from base64 99 | dataBytes, err := base64.StdEncoding.DecodeString(data) 100 | if err != nil { 101 | return "", nil, fmt.Errorf("invalid data: %v", err) 102 | } 103 | // Decode nonce from base64 104 | nonceBytes, err := base64.StdEncoding.DecodeString(nonce) 105 | if err != nil { 106 | return "", nil, fmt.Errorf("invalid nonce: %v", err) 107 | } 108 | // Decrypt data 109 | decryptedData, err := decryptChaCha20poly1305(dataBytes, key, nonceBytes) 110 | if err != nil { 111 | return "", nil, fmt.Errorf("failed to decrypt data: %v", err) 112 | } 113 | return base64.StdEncoding.EncodeToString(decryptedData), decryptedData, nil 114 | } 115 | -------------------------------------------------------------------------------- /internal/crypto/crypto_libsodium.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "github.com/ente-io/cli/utils/encoding" 7 | "golang.org/x/crypto/nacl/box" 8 | "golang.org/x/crypto/nacl/secretbox" 9 | "io" 10 | "log" 11 | "os" 12 | ) 13 | 14 | //func EncryptChaCha20poly1305LibSodium(data []byte, key []byte) ([]byte, []byte, error) { 15 | // var buf bytes.Buffer 16 | // encoder := sodium.MakeSecretStreamXCPEncoder(sodium.SecretStreamXCPKey{Bytes: key}, &buf) 17 | // _, err := encoder.WriteAndClose(data) 18 | // if err != nil { 19 | // log.Println("Failed to write to encoder", err) 20 | // return nil, nil, err 21 | // } 22 | // return buf.Bytes(), encoder.Header().Bytes, nil 23 | //} 24 | 25 | // EncryptChaCha20poly1305 encrypts the given data using the ChaCha20-Poly1305 algorithm. 26 | // Parameters: 27 | // - data: The plaintext data as a byte slice. 28 | // - key: The key for encryption as a byte slice. 29 | // 30 | // Returns: 31 | // - A byte slice representing the encrypted data. 32 | // - A byte slice representing the header of the encrypted data. 33 | // - An error object, which is nil if no error occurs. 34 | func EncryptChaCha20poly1305(data []byte, key []byte) ([]byte, []byte, error) { 35 | encryptor, header, err := NewEncryptor(key) 36 | if err != nil { 37 | return nil, nil, err 38 | } 39 | encoded, err := encryptor.Push(data, TagFinal) 40 | if err != nil { 41 | return nil, nil, err 42 | } 43 | return encoded, header, nil 44 | } 45 | 46 | // decryptChaCha20poly1305 decrypts the given data using the ChaCha20-Poly1305 algorithm. 47 | // Parameters: 48 | // - data: The encrypted data as a byte slice. 49 | // - key: The key for decryption as a byte slice. 50 | // - nonce: The nonce for decryption as a byte slice. 51 | // 52 | // Returns: 53 | // - A byte slice representing the decrypted data. 54 | // - An error object, which is nil if no error occurs. 55 | //func decryptChaCha20poly1305LibSodium(data []byte, key []byte, nonce []byte) ([]byte, error) { 56 | // reader := bytes.NewReader(data) 57 | // header := sodium.SecretStreamXCPHeader{Bytes: nonce} 58 | // decoder, err := sodium.MakeSecretStreamXCPDecoder( 59 | // sodium.SecretStreamXCPKey{Bytes: key}, 60 | // reader, 61 | // header) 62 | // if err != nil { 63 | // log.Println("Failed to make secret stream decoder", err) 64 | // return nil, err 65 | // } 66 | // // Buffer to store the decrypted data 67 | // decryptedData := make([]byte, len(data)) 68 | // n, err := decoder.Read(decryptedData) 69 | // if err != nil && err != io.EOF { 70 | // log.Println("Failed to read from decoder", err) 71 | // return nil, err 72 | // } 73 | // return decryptedData[:n], nil 74 | //} 75 | 76 | func decryptChaCha20poly1305(data []byte, key []byte, nonce []byte) ([]byte, error) { 77 | decryptor, err := NewDecryptor(key, nonce) 78 | if err != nil { 79 | return nil, err 80 | } 81 | decoded, tag, err := decryptor.Pull(data) 82 | if tag != TagFinal { 83 | return nil, errors.New("invalid tag") 84 | } 85 | if err != nil { 86 | return nil, err 87 | } 88 | return decoded, nil 89 | } 90 | 91 | //func SecretBoxOpenLibSodium(c []byte, n []byte, k []byte) ([]byte, error) { 92 | // var cp sodium.Bytes = c 93 | // res, err := cp.SecretBoxOpen(sodium.SecretBoxNonce{Bytes: n}, sodium.SecretBoxKey{Bytes: k}) 94 | // return res, err 95 | //} 96 | 97 | func SecretBoxOpenBase64(cipher string, nonce string, k []byte) ([]byte, error) { 98 | return SecretBoxOpen(encoding.DecodeBase64(cipher), encoding.DecodeBase64(nonce), k) 99 | } 100 | 101 | func SecretBoxOpen(c []byte, n []byte, k []byte) ([]byte, error) { 102 | // Check for valid lengths of nonce and key 103 | if len(n) != 24 || len(k) != 32 { 104 | return nil, ErrOpenBox 105 | } 106 | 107 | var nonce [24]byte 108 | var key [32]byte 109 | copy(nonce[:], n) 110 | copy(key[:], k) 111 | 112 | // Decrypt the message using Go's nacl/secretbox 113 | decrypted, ok := secretbox.Open(nil, c, &nonce, &key) 114 | if !ok { 115 | return nil, ErrOpenBox 116 | } 117 | 118 | return decrypted, nil 119 | } 120 | 121 | //func SealedBoxOpenLib(cipherText []byte, publicKey, masterSecret []byte) ([]byte, error) { 122 | // var cp sodium.Bytes = cipherText 123 | // om, err := cp.SealedBoxOpen(sodium.BoxKP{ 124 | // PublicKey: sodium.BoxPublicKey{Bytes: publicKey}, 125 | // SecretKey: sodium.BoxSecretKey{Bytes: masterSecret}, 126 | // }) 127 | // if err != nil { 128 | // return nil, fmt.Errorf("failed to open sealed box: %v", err) 129 | // } 130 | // return om, nil 131 | //} 132 | 133 | func SealedBoxOpen(cipherText, publicKey, masterSecret []byte) ([]byte, error) { 134 | if len(cipherText) < BoxSealBytes { 135 | return nil, ErrOpenBox 136 | } 137 | 138 | // Extract ephemeral public key from the ciphertext 139 | var ephemeralPublicKey [32]byte 140 | copy(ephemeralPublicKey[:], publicKey[:32]) 141 | 142 | // Extract ephemeral public key from the ciphertext 143 | var masterKey [32]byte 144 | copy(masterKey[:], masterSecret[:32]) 145 | 146 | // Decrypt the message using nacl/box 147 | decrypted, ok := box.OpenAnonymous(nil, cipherText, &ephemeralPublicKey, &masterKey) 148 | if !ok { 149 | return nil, ErrOpenBox 150 | } 151 | 152 | return decrypted, nil 153 | } 154 | 155 | func DecryptFile(encryptedFilePath string, decryptedFilePath string, key, nonce []byte) error { 156 | inputFile, err := os.Open(encryptedFilePath) 157 | if err != nil { 158 | return err 159 | } 160 | defer inputFile.Close() 161 | 162 | outputFile, err := os.Create(decryptedFilePath) 163 | if err != nil { 164 | return err 165 | } 166 | defer outputFile.Close() 167 | 168 | reader := bufio.NewReader(inputFile) 169 | writer := bufio.NewWriter(outputFile) 170 | 171 | decryptor, err := NewDecryptor(key, nonce) 172 | if err != nil { 173 | return err 174 | } 175 | 176 | buf := make([]byte, decryptionBufferSize+XChaCha20Poly1305IetfABYTES) 177 | for { 178 | readCount, err := reader.Read(buf) 179 | if err != nil && err != io.EOF { 180 | log.Println("Failed to read from input file", err) 181 | return err 182 | } 183 | if readCount == 0 { 184 | break 185 | } 186 | n, tag, errErr := decryptor.Pull(buf[:readCount]) 187 | if errErr != nil && errErr != io.EOF { 188 | log.Println("Failed to read from decoder", errErr) 189 | return errErr 190 | } 191 | 192 | if _, err := writer.Write(n); err != nil { 193 | log.Println("Failed to write to output file", err) 194 | return err 195 | } 196 | if errErr == io.EOF { 197 | break 198 | } 199 | if tag == TagFinal { 200 | break 201 | } 202 | } 203 | if err := writer.Flush(); err != nil { 204 | log.Println("Failed to flush writer", err) 205 | return err 206 | } 207 | return nil 208 | } 209 | 210 | //func DecryptFileLib(encryptedFilePath string, decryptedFilePath string, key, nonce []byte) error { 211 | // inputFile, err := os.Open(encryptedFilePath) 212 | // if err != nil { 213 | // return err 214 | // } 215 | // defer inputFile.Close() 216 | // 217 | // outputFile, err := os.Create(decryptedFilePath) 218 | // if err != nil { 219 | // return err 220 | // } 221 | // defer outputFile.Close() 222 | // 223 | // reader := bufio.NewReader(inputFile) 224 | // writer := bufio.NewWriter(outputFile) 225 | // 226 | // header := sodium.SecretStreamXCPHeader{Bytes: nonce} 227 | // decoder, err := sodium.MakeSecretStreamXCPDecoder( 228 | // sodium.SecretStreamXCPKey{Bytes: key}, 229 | // reader, 230 | // header) 231 | // if err != nil { 232 | // log.Println("Failed to make secret stream decoder", err) 233 | // return err 234 | // } 235 | // 236 | // buf := make([]byte, decryptionBufferSize) 237 | // for { 238 | // n, errErr := decoder.Read(buf) 239 | // if errErr != nil && errErr != io.EOF { 240 | // log.Println("Failed to read from decoder", errErr) 241 | // return errErr 242 | // } 243 | // if n == 0 { 244 | // break 245 | // } 246 | // if _, err := writer.Write(buf[:n]); err != nil { 247 | // log.Println("Failed to write to output file", err) 248 | // return err 249 | // } 250 | // if errErr == io.EOF { 251 | // break 252 | // } 253 | // } 254 | // if err := writer.Flush(); err != nil { 255 | // log.Println("Failed to flush writer", err) 256 | // return err 257 | // } 258 | // return nil 259 | //} 260 | -------------------------------------------------------------------------------- /internal/crypto/crypto_test.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "testing" 7 | ) 8 | 9 | const ( 10 | password = "test_password" 11 | kdfSalt = "vd0dcYMGNLKn/gpT6uTFTw==" 12 | memLimit = 64 * 1024 * 1024 // 64MB 13 | opsLimit = 2 14 | cipherText = "kBXQ2PuX6y/aje5r22H0AehRPh6sQ0ULoeAO" 15 | cipherNonce = "v7wsI+BFZsRMIjDm3rTxPhmi/CaUdkdJ" 16 | expectedPlainText = "plain_text" 17 | expectedDerivedKey = "vp8d8Nee0BbIML4ab8Cp34uYnyrN77cRwTl920flyT0=" 18 | ) 19 | 20 | func TestDeriveArgonKey(t *testing.T) { 21 | derivedKey, err := DeriveArgonKey(password, kdfSalt, memLimit, opsLimit) 22 | if err != nil { 23 | t.Fatalf("Failed to derive key: %v", err) 24 | } 25 | 26 | if base64.StdEncoding.EncodeToString(derivedKey) != expectedDerivedKey { 27 | t.Fatalf("Derived key does not match expected key") 28 | } 29 | } 30 | 31 | func TestDecryptChaCha20poly1305(t *testing.T) { 32 | derivedKey, err := DeriveArgonKey(password, kdfSalt, memLimit, opsLimit) 33 | if err != nil { 34 | t.Fatalf("Failed to derive key: %v", err) 35 | } 36 | decodedCipherText, err := base64.StdEncoding.DecodeString(cipherText) 37 | if err != nil { 38 | t.Fatalf("Failed to decode cipher text: %v", err) 39 | } 40 | 41 | decodedCipherNonce, err := base64.StdEncoding.DecodeString(cipherNonce) 42 | if err != nil { 43 | t.Fatalf("Failed to decode cipher nonce: %v", err) 44 | } 45 | 46 | decryptedText, err := decryptChaCha20poly1305(decodedCipherText, derivedKey, decodedCipherNonce) 47 | if err != nil { 48 | t.Fatalf("Failed to decrypt: %v", err) 49 | } 50 | if string(decryptedText) != expectedPlainText { 51 | t.Fatalf("Decrypted text : %s does not match the expected text: %s", string(decryptedText), expectedPlainText) 52 | } 53 | } 54 | 55 | func TestEncryptAndDecryptChaCha20Ploy1305(t *testing.T) { 56 | key := make([]byte, 32) 57 | _, err := rand.Read(key) 58 | if err != nil { 59 | t.Fatalf("Failed to generate random key: %v", err) 60 | } 61 | cipher, nonce, err := EncryptChaCha20poly1305([]byte("plain_text"), key) 62 | if err != nil { 63 | return 64 | } 65 | plainText, err := decryptChaCha20poly1305(cipher, key, nonce) 66 | if err != nil { 67 | t.Fatalf("Failed to decrypt: %v", err) 68 | } 69 | if string(plainText) != "plain_text" { 70 | t.Fatalf("Decrypted text : %s does not match the expected text: %s", string(plainText), "plain_text") 71 | } 72 | } 73 | 74 | func TestSecretBoxOpenBase64(t *testing.T) { 75 | sealedCipherText := "KHwRN+RzvTu+jC7mCdkMsqnTPSLvevtZILmcR2OYFbIRPqDyjAl+m8KxD9B5fiEo" 76 | sealNonce := "jgfPDOsQh2VdIHWJVSBicMPF2sQW3HIY" 77 | sealKey, _ := base64.StdEncoding.DecodeString("kercNpvGufMTTHmDwAhz26DgCAvznd1+/buBqKEkWr4=") 78 | expectedSealedText := "O1ObUBMv+SCE1qWHD7+WViEIZcAeTp18Y+m9eMlDE1Y=" 79 | 80 | plainText, err := SecretBoxOpenBase64(sealedCipherText, sealNonce, sealKey) 81 | if err != nil { 82 | t.Fatalf("Failed to decrypt: %v", err) 83 | } 84 | 85 | if expectedSealedText != base64.StdEncoding.EncodeToString(plainText) { 86 | t.Fatalf("Decrypted text : %s does not match the expected text: %s", string(plainText), expectedSealedText) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /internal/crypto/stream.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "encoding/binary" 7 | "errors" 8 | "fmt" 9 | "golang.org/x/crypto/chacha20" 10 | "golang.org/x/crypto/chacha20poly1305" 11 | "golang.org/x/crypto/poly1305" 12 | ) 13 | 14 | // public constants 15 | const ( 16 | //TagMessage the most common tag, that doesn't add any information about the nature of the message. 17 | TagMessage = 0 18 | // TagPush indicates that the message marks the end of a set of messages, 19 | // 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. 20 | TagPush = 0x01 21 | // TagRekey "forget" the key used to encrypt this message and the previous ones, and derive a new secret key. 22 | TagRekey = 0x02 23 | // TagFinal indicates that the message marks the end of the stream, and erases the secret key used to encrypt the previous sequence. 24 | TagFinal = TagPush | TagRekey 25 | 26 | StreamKeyBytes = chacha20poly1305.KeySize 27 | StreamHeaderBytes = chacha20poly1305.NonceSizeX 28 | // XChaCha20Poly1305IetfABYTES links to crypto_secretstream_xchacha20poly1305_ABYTES 29 | XChaCha20Poly1305IetfABYTES = 16 + 1 30 | ) 31 | 32 | const cryptoCoreHchacha20InputBytes = 16 33 | 34 | /* const crypto_secretstream_xchacha20poly1305_INONCEBYTES = 8 */ 35 | const cryptoSecretStreamXchacha20poly1305Counterbytes = 4 36 | 37 | var pad0 [16]byte 38 | 39 | var invalidKey = errors.New("invalid key") 40 | var invalidInput = errors.New("invalid input") 41 | var cryptoFailure = errors.New("crypto failed") 42 | 43 | // crypto_secretstream_xchacha20poly1305_state 44 | type streamState struct { 45 | k [StreamKeyBytes]byte 46 | nonce [chacha20poly1305.NonceSize]byte 47 | pad [8]byte 48 | } 49 | 50 | func (s *streamState) reset() { 51 | for i := range s.nonce { 52 | s.nonce[i] = 0 53 | } 54 | s.nonce[0] = 1 55 | } 56 | 57 | type Encryptor interface { 58 | Push(m []byte, tag byte) ([]byte, error) 59 | } 60 | 61 | type Decryptor interface { 62 | Pull(m []byte) ([]byte, byte, error) 63 | } 64 | 65 | type encryptor struct { 66 | streamState 67 | } 68 | 69 | type decryptor struct { 70 | streamState 71 | } 72 | 73 | func NewStreamKey() []byte { 74 | k := make([]byte, chacha20poly1305.KeySize) 75 | _, _ = rand.Read(k) 76 | return k 77 | } 78 | 79 | func NewEncryptor(key []byte) (Encryptor, []byte, error) { 80 | if len(key) != StreamKeyBytes { 81 | return nil, nil, invalidKey 82 | } 83 | 84 | header := make([]byte, StreamHeaderBytes) 85 | _, _ = rand.Read(header) 86 | 87 | stream := &encryptor{} 88 | 89 | k, err := chacha20.HChaCha20(key[:], header[:16]) 90 | if err != nil { 91 | //fmt.Printf("error: %v", err) 92 | return nil, nil, err 93 | } 94 | copy(stream.k[:], k) 95 | stream.reset() 96 | 97 | for i := range stream.pad { 98 | stream.pad[i] = 0 99 | } 100 | 101 | for i, b := range header[cryptoCoreHchacha20InputBytes:] { 102 | stream.nonce[i+cryptoSecretStreamXchacha20poly1305Counterbytes] = b 103 | } 104 | // fmt.Printf("stream: %+v\n", stream.streamState) 105 | 106 | return stream, header, nil 107 | } 108 | 109 | func (s *encryptor) Push(plain []byte, tag byte) ([]byte, error) { 110 | var err error 111 | 112 | //crypto_onetimeauth_poly1305_state poly1305_state; 113 | var poly *poly1305.MAC 114 | 115 | //unsigned char block[64U]; 116 | var block [64]byte 117 | 118 | //unsigned char slen[8U]; 119 | var slen [8]byte 120 | 121 | //unsigned char *c; 122 | //unsigned char *mac; 123 | // 124 | //if (outlen_p != NULL) { 125 | //*outlen_p = 0U; 126 | //} 127 | 128 | mlen := len(plain) 129 | //if (mlen > crypto_secretstream_xchacha20poly1305_MESSAGEBYTES_MAX) { 130 | //sodium_misuse(); 131 | //} 132 | 133 | out := make([]byte, mlen+XChaCha20Poly1305IetfABYTES) 134 | 135 | chacha, err := chacha20.NewUnauthenticatedCipher(s.k[:], s.nonce[:]) 136 | if err != nil { 137 | return nil, err 138 | } 139 | //crypto_stream_chacha20_ietf(block, sizeof block, state->nonce, state->k); 140 | chacha.XORKeyStream(block[:], block[:]) 141 | 142 | //crypto_onetimeauth_poly1305_init(&poly1305_state, block); 143 | var poly_init [32]byte 144 | copy(poly_init[:], block[:]) 145 | poly = poly1305.New(&poly_init) 146 | 147 | // TODO add support for add data 148 | //sodium_memzero(block, sizeof block); 149 | //crypto_onetimeauth_poly1305_update(&poly1305_state, ad, adlen); 150 | //crypto_onetimeauth_poly1305_update(&poly1305_state, _pad0, 151 | //(0x10 - adlen) & 0xf); 152 | 153 | //memset(block, 0, sizeof block); 154 | //block[0] = tag; 155 | memZero(block[:]) 156 | block[0] = tag 157 | 158 | // 159 | //crypto_stream_chacha20_ietf_xor_ic(block, block, sizeof block, state->nonce, 1U, state->k); 160 | //crypto_onetimeauth_poly1305_update(&poly1305_state, block, sizeof block); 161 | //out[0] = block[0]; 162 | chacha.XORKeyStream(block[:], block[:]) 163 | _, _ = poly.Write(block[:]) 164 | out[0] = block[0] 165 | 166 | // 167 | //c = out + (sizeof tag); 168 | c := out[1:] 169 | //crypto_stream_chacha20_ietf_xor_ic(c, m, mlen, state->nonce, 2U, state->k); 170 | //crypto_onetimeauth_poly1305_update(&poly1305_state, c, mlen); 171 | //crypto_onetimeauth_poly1305_update (&poly1305_state, _pad0, (0x10 - (sizeof block) + mlen) & 0xf); 172 | chacha.XORKeyStream(c, plain) 173 | _, _ = poly.Write(c[:mlen]) 174 | padlen := (0x10 - len(block) + mlen) & 0xf 175 | _, _ = poly.Write(pad0[:padlen]) 176 | 177 | // 178 | //STORE64_LE(slen, (uint64_t) adlen); 179 | //crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen); 180 | binary.LittleEndian.PutUint64(slen[:], uint64(0)) 181 | _, _ = poly.Write(slen[:]) 182 | 183 | //STORE64_LE(slen, (sizeof block) + mlen); 184 | //crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen); 185 | binary.LittleEndian.PutUint64(slen[:], uint64(len(block)+mlen)) 186 | _, _ = poly.Write(slen[:]) 187 | 188 | // 189 | //mac = c + mlen; 190 | //crypto_onetimeauth_poly1305_final(&poly1305_state, mac); 191 | mac := c[mlen:] 192 | copy(mac, poly.Sum(nil)) 193 | //sodium_memzero(&poly1305_state, sizeof poly1305_state); 194 | // 195 | 196 | //XOR_BUF(STATE_INONCE(state), mac, crypto_secretstream_xchacha20poly1305_INONCEBYTES); 197 | //sodium_increment(STATE_COUNTER(state), crypto_secretstream_xchacha20poly1305_COUNTERBYTES); 198 | xorBuf(s.nonce[cryptoSecretStreamXchacha20poly1305Counterbytes:], mac) 199 | bufInc(s.nonce[:cryptoSecretStreamXchacha20poly1305Counterbytes]) 200 | 201 | // TODO 202 | //if ((tag & crypto_secretstream_xchacha20poly1305_TAG_REKEY) != 0 || 203 | //sodium_is_zero(STATE_COUNTER(state), 204 | //crypto_secretstream_xchacha20poly1305_COUNTERBYTES)) { 205 | //crypto_secretstream_xchacha20poly1305_rekey(state); 206 | //} 207 | 208 | //if (outlen_p != NULL) { 209 | //*outlen_p = crypto_secretstream_xchacha20poly1305_ABYTES + mlen; 210 | //} 211 | 212 | //return 0; 213 | return out, nil 214 | } 215 | 216 | func NewDecryptor(key, header []byte) (Decryptor, error) { 217 | stream := &decryptor{} 218 | 219 | //crypto_core_hchacha20(state->k, in, k, NULL); 220 | k, err := chacha20.HChaCha20(key, header[:16]) 221 | if err != nil { 222 | fmt.Printf("error: %v", err) 223 | return nil, err 224 | } 225 | copy(stream.k[:], k) 226 | 227 | //_crypto_secretstream_xchacha20poly1305_counter_reset(state); 228 | stream.reset() 229 | 230 | //memcpy(STATE_INONCE(state), in + crypto_core_hchacha20_INPUTBYTES, 231 | // crypto_secretstream_xchacha20poly1305_INONCEBYTES); 232 | copy(stream.nonce[cryptoSecretStreamXchacha20poly1305Counterbytes:], 233 | header[cryptoCoreHchacha20InputBytes:]) 234 | 235 | //memset(state->_pad, 0, sizeof state->_pad); 236 | copy(stream.pad[:], pad0[:]) 237 | 238 | //fmt.Printf("decryptor: %+v\n", stream.streamState) 239 | 240 | return stream, nil 241 | } 242 | 243 | func (s *decryptor) Pull(cipher []byte) ([]byte, byte, error) { 244 | cipherLen := len(cipher) 245 | 246 | //crypto_onetimeauth_poly1305_state poly1305_state; 247 | var poly1305State [32]byte 248 | 249 | //unsigned char block[64U]; 250 | var block [64]byte 251 | //unsigned char slen[8U]; 252 | var slen [8]byte 253 | 254 | //unsigned char mac[crypto_onetimeauth_poly1305_BYTES]; 255 | //const unsigned char *c; 256 | //const unsigned char *stored_mac; 257 | //unsigned long long mlen; // length of the returned message 258 | //unsigned char tag; // for the return value 259 | // 260 | //if (mlen_p != NULL) { 261 | //*mlen_p = 0U; 262 | //} 263 | //if (tag_p != NULL) { 264 | //*tag_p = 0xff; 265 | //} 266 | 267 | /* 268 | if (inlen < crypto_secretstream_xchacha20poly1305_ABYTES) { 269 | return -1; 270 | } 271 | mlen = inlen - crypto_secretstream_xchacha20poly1305_ABYTES; 272 | */ 273 | if cipherLen < XChaCha20Poly1305IetfABYTES { 274 | return nil, 0, invalidInput 275 | } 276 | mlen := cipherLen - XChaCha20Poly1305IetfABYTES 277 | 278 | //if (mlen > crypto_secretstream_xchacha20poly1305_MESSAGEBYTES_MAX) { 279 | //sodium_misuse(); 280 | //} 281 | 282 | //crypto_stream_chacha20_ietf(block, sizeof block, state->nonce, state->k); 283 | chacha, err := chacha20.NewUnauthenticatedCipher(s.k[:], s.nonce[:]) 284 | if err != nil { 285 | return nil, 0, err 286 | } 287 | chacha.XORKeyStream(block[:], block[:]) 288 | 289 | //crypto_onetimeauth_poly1305_init(&poly1305_state, block); 290 | 291 | copy(poly1305State[:], block[:]) 292 | poly := poly1305.New(&poly1305State) 293 | 294 | // TODO 295 | //sodium_memzero(block, sizeof block); 296 | //crypto_onetimeauth_poly1305_update(&poly1305_state, ad, adlen); 297 | //crypto_onetimeauth_poly1305_update(&poly1305_state, _pad0, 298 | //(0x10 - adlen) & 0xf); 299 | // 300 | 301 | //memset(block, 0, sizeof block); 302 | //block[0] = in[0]; 303 | //crypto_stream_chacha20_ietf_xor_ic(block, block, sizeof block, state->nonce, 1U, state->k); 304 | memZero(block[:]) 305 | block[0] = cipher[0] 306 | chacha.XORKeyStream(block[:], block[:]) 307 | 308 | //tag = block[0]; 309 | //block[0] = in[0]; 310 | //crypto_onetimeauth_poly1305_update(&poly1305_state, block, sizeof block); 311 | tag := block[0] 312 | block[0] = cipher[0] 313 | if _, err = poly.Write(block[:]); err != nil { 314 | return nil, 0, err 315 | } 316 | 317 | //c = in + (sizeof tag); 318 | //crypto_onetimeauth_poly1305_update(&poly1305_state, c, mlen); 319 | //crypto_onetimeauth_poly1305_update (&poly1305_state, _pad0, (0x10 - (sizeof block) + mlen) & 0xf); 320 | c := cipher[1:] 321 | if _, err = poly.Write(c[:mlen]); err != nil { 322 | return nil, 0, err 323 | } 324 | padLen := (0x10 - len(block) + mlen) & 0xf 325 | if _, err = poly.Write(pad0[:padLen]); err != nil { 326 | return nil, 0, err 327 | } 328 | 329 | // 330 | //STORE64_LE(slen, (uint64_t) adlen); 331 | //crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen); 332 | binary.LittleEndian.PutUint64(slen[:], uint64(0)) 333 | if _, err = poly.Write(slen[:]); err != nil { 334 | return nil, 0, err 335 | } 336 | 337 | //STORE64_LE(slen, (sizeof block) + mlen); 338 | //crypto_onetimeauth_poly1305_update(&poly1305_state, slen, sizeof slen); 339 | binary.LittleEndian.PutUint64(slen[:], uint64(len(block)+mlen)) 340 | if _, err = poly.Write(slen[:]); err != nil { 341 | return nil, 0, err 342 | } 343 | 344 | // 345 | //crypto_onetimeauth_poly1305_final(&poly1305_state, mac); 346 | //sodium_memzero(&poly1305_state, sizeof poly1305_state); 347 | 348 | mac := poly.Sum(nil) 349 | memZero(poly1305State[:]) 350 | 351 | //stored_mac = c + mlen; 352 | //if (sodium_memcmp(mac, stored_mac, sizeof mac) != 0) { 353 | //sodium_memzero(mac, sizeof mac); 354 | //return -1; 355 | //} 356 | storedMac := c[mlen:] 357 | if !bytes.Equal(mac, storedMac) { 358 | memZero(mac) 359 | return nil, 0, cryptoFailure 360 | } 361 | 362 | //crypto_stream_chacha20_ietf_xor_ic(m, c, mlen, state->nonce, 2U, state->k); 363 | //XOR_BUF(STATE_INONCE(state), mac, crypto_secretstream_xchacha20poly1305_INONCEBYTES); 364 | //sodium_increment(STATE_COUNTER(state), crypto_secretstream_xchacha20poly1305_COUNTERBYTES); 365 | m := make([]byte, mlen) 366 | chacha.XORKeyStream(m, c[:mlen]) 367 | 368 | xorBuf(s.nonce[cryptoSecretStreamXchacha20poly1305Counterbytes:], mac) 369 | bufInc(s.nonce[:cryptoSecretStreamXchacha20poly1305Counterbytes]) 370 | 371 | // TODO 372 | //if ((tag & crypto_secretstream_xchacha20poly1305_TAG_REKEY) != 0 || 373 | //sodium_is_zero(STATE_COUNTER(state), 374 | //crypto_secretstream_xchacha20poly1305_COUNTERBYTES)) { 375 | //crypto_secretstream_xchacha20poly1305_rekey(state); 376 | //} 377 | 378 | //if (mlen_p != NULL) { 379 | //*mlen_p = mlen; 380 | //} 381 | //if (tag_p != NULL) { 382 | //*tag_p = tag; 383 | //} 384 | //return 0; 385 | return m, tag, nil 386 | } 387 | -------------------------------------------------------------------------------- /internal/crypto/utils.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | func memZero(b []byte) { 4 | for i := range b { 5 | b[i] = 0 6 | } 7 | } 8 | 9 | func xorBuf(out, in []byte) { 10 | for i := range out { 11 | out[i] ^= in[i] 12 | } 13 | } 14 | 15 | func bufInc(n []byte) { 16 | c := 1 17 | 18 | for i := range n { 19 | c += int(n[i]) 20 | n[i] = byte(c) 21 | c >>= 8 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/promt.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "github.com/ente-io/cli/internal/api" 8 | "log" 9 | "os" 10 | "strings" 11 | 12 | "golang.org/x/term" 13 | ) 14 | 15 | func GetSensitiveField(label string) (string, error) { 16 | fmt.Printf("%s: ", label) 17 | input, err := term.ReadPassword(int(os.Stdin.Fd())) 18 | if err != nil { 19 | return "", err 20 | } 21 | return string(input), nil 22 | } 23 | 24 | func GetUserInput(label string) (string, error) { 25 | fmt.Printf("%s: ", label) 26 | var input string 27 | reader := bufio.NewReader(os.Stdin) 28 | input, err := reader.ReadString('\n') 29 | //_, err := fmt.Scanln(&input) 30 | if err != nil { 31 | return "", err 32 | } 33 | input = strings.TrimSpace(input) 34 | if input == "" { 35 | return "", errors.New("input cannot be empty") 36 | } 37 | return input, nil 38 | } 39 | 40 | func GetAppType() api.App { 41 | for { 42 | app, err := GetUserInput("Enter app type (default: photos)") 43 | if err != nil { 44 | fmt.Printf("Use default app type: %s\n", api.AppPhotos) 45 | return api.AppPhotos 46 | } 47 | switch app { 48 | case "photos": 49 | return api.AppPhotos 50 | case "auth": 51 | return api.AppAuth 52 | case "locker": 53 | return api.AppLocker 54 | case "": 55 | return api.AppPhotos 56 | default: 57 | fmt.Println("invalid app type") 58 | continue 59 | } 60 | } 61 | } 62 | 63 | func GetCode(promptText string, length int) (string, error) { 64 | for { 65 | ott, err := GetUserInput(promptText) 66 | if err != nil { 67 | return "", err 68 | } 69 | if ott == "" { 70 | log.Fatal("no OTP entered") 71 | return "", errors.New("no OTP entered") 72 | } 73 | if ott == "c" { 74 | return "", errors.New("OTP entry cancelled") 75 | } 76 | if len(ott) != length { 77 | fmt.Printf("OTP must be %d digits", length) 78 | continue 79 | } 80 | return ott, nil 81 | } 82 | } 83 | 84 | func GetExportDir() string { 85 | for { 86 | exportDir, err := GetUserInput("Enter export directory") 87 | if err != nil { 88 | log.Printf("invalid export directory input: %s\n", err) 89 | return "" 90 | } 91 | if exportDir == "" { 92 | log.Printf("invalid export directory: %s\n", err) 93 | continue 94 | } 95 | exportDir, err = ResolvePath(exportDir) 96 | if err != nil { 97 | log.Printf("invalid export directory: %s\n", err) 98 | continue 99 | } 100 | _, err = ValidateDirForWrite(exportDir) 101 | if err != nil { 102 | log.Printf("invalid export directory: %s\n", err) 103 | continue 104 | } 105 | 106 | return exportDir 107 | } 108 | } 109 | 110 | func ValidateDirForWrite(dir string) (bool, error) { 111 | // Check if the path exists 112 | fileInfo, err := os.Stat(dir) 113 | if err != nil { 114 | if os.IsNotExist(err) { 115 | return false, fmt.Errorf("path does not exist: %s", dir) 116 | } 117 | return false, err 118 | } 119 | 120 | // Check if the path is a directory 121 | if !fileInfo.IsDir() { 122 | return false, fmt.Errorf("path is not a directory") 123 | } 124 | 125 | // Check for write permission 126 | // Check for write permission by creating a temp file 127 | tempFile, err := os.CreateTemp(dir, "write_test_") 128 | if err != nil { 129 | return false, fmt.Errorf("write permission denied: %v", err) 130 | } 131 | 132 | // Delete temp file 133 | defer os.Remove(tempFile.Name()) 134 | if err != nil { 135 | return false, err 136 | } 137 | 138 | return true, nil 139 | } 140 | 141 | func ResolvePath(path string) (string, error) { 142 | if path[:2] != "~/" { 143 | return path, nil 144 | } 145 | home, err := os.UserHomeDir() 146 | if err != nil { 147 | return "", err 148 | } 149 | return home + path[1:], nil 150 | } 151 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ente-io/cli/cmd" 6 | "github.com/ente-io/cli/internal" 7 | "github.com/ente-io/cli/internal/api" 8 | "github.com/ente-io/cli/pkg" 9 | "github.com/ente-io/cli/pkg/secrets" 10 | "github.com/ente-io/cli/utils/constants" 11 | "log" 12 | "os" 13 | "path/filepath" 14 | "strings" 15 | ) 16 | 17 | func main() { 18 | cliDBPath, err := GetCLIConfigPath() 19 | if secrets.IsRunningInContainer() { 20 | cliDBPath = constants.CliDataPath 21 | _, err := internal.ValidateDirForWrite(cliDBPath) 22 | if err != nil { 23 | log.Fatalf("Please mount a volume to %s to persist cli data\n%v\n", cliDBPath, err) 24 | } 25 | } 26 | 27 | if err != nil { 28 | log.Fatalf("Could not create cli config path\n%v\n", err) 29 | } 30 | newCliPath := fmt.Sprintf("%s/ente-cli.db", cliDBPath) 31 | if !strings.HasPrefix(cliDBPath, "/") { 32 | oldCliPath := fmt.Sprintf("%sente-cli.db", cliDBPath) 33 | if _, err := os.Stat(oldCliPath); err == nil { 34 | log.Printf("migrating old cli db from %s to %s\n", oldCliPath, newCliPath) 35 | if err := os.Rename(oldCliPath, newCliPath); err != nil { 36 | log.Fatalf("Could not rename old cli db\n%v\n", err) 37 | } 38 | } 39 | } 40 | db, err := pkg.GetDB(newCliPath) 41 | 42 | if err != nil { 43 | if strings.Contains(err.Error(), "timeout") { 44 | log.Fatalf("Please close all other instances of the cli and try again\n%v\n", err) 45 | } else { 46 | panic(err) 47 | } 48 | } 49 | ctrl := pkg.ClICtrl{ 50 | Client: api.NewClient(api.Params{ 51 | Debug: false, 52 | //Host: "http://localhost:8080", 53 | }), 54 | DB: db, 55 | KeyHolder: secrets.NewKeyHolder(secrets.GetOrCreateClISecret()), 56 | } 57 | err = ctrl.Init() 58 | if err != nil { 59 | panic(err) 60 | } 61 | defer func() { 62 | if err := db.Close(); err != nil { 63 | panic(err) 64 | } 65 | }() 66 | cmd.Execute(&ctrl) 67 | } 68 | 69 | // GetCLIConfigPath returns the path to the .ente-cli folder and creates it if it doesn't exist. 70 | func GetCLIConfigPath() (string, error) { 71 | if os.Getenv("ENTE_CLI_CONFIG_PATH") != "" { 72 | return os.Getenv("ENTE_CLI_CONFIG_PATH"), nil 73 | } 74 | // Get the user's home directory 75 | homeDir, err := os.UserHomeDir() 76 | if err != nil { 77 | return "", err 78 | } 79 | 80 | cliDBPath := filepath.Join(homeDir, ".ente") 81 | 82 | // Check if the folder already exists, if not, create it 83 | if _, err := os.Stat(cliDBPath); os.IsNotExist(err) { 84 | err := os.MkdirAll(cliDBPath, 0755) 85 | if err != nil { 86 | return "", err 87 | } 88 | } 89 | 90 | return cliDBPath, nil 91 | } 92 | -------------------------------------------------------------------------------- /pkg/account.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/ente-io/cli/internal" 8 | "github.com/ente-io/cli/internal/api" 9 | "github.com/ente-io/cli/pkg/model" 10 | "github.com/ente-io/cli/utils/encoding" 11 | "log" 12 | 13 | bolt "go.etcd.io/bbolt" 14 | ) 15 | 16 | const AccBucket = "accounts" 17 | 18 | func (c *ClICtrl) AddAccount(cxt context.Context) { 19 | var flowErr error 20 | defer func() { 21 | if flowErr != nil { 22 | log.Fatal(flowErr) 23 | } 24 | }() 25 | app := internal.GetAppType() 26 | cxt = context.WithValue(cxt, "app", string(app)) 27 | dir := internal.GetExportDir() 28 | if dir == "" { 29 | flowErr = fmt.Errorf("export directory not set") 30 | return 31 | } 32 | email, flowErr := internal.GetUserInput("Enter email address") 33 | if flowErr != nil { 34 | return 35 | } 36 | var verifyEmail bool 37 | 38 | srpAttr, flowErr := c.Client.GetSRPAttributes(cxt, email) 39 | if flowErr != nil { 40 | // if flowErr type is ApiError and status code is 404, then set verifyEmail to true and continue 41 | // else return 42 | if apiErr, ok := flowErr.(*api.ApiError); ok && apiErr.StatusCode == 404 { 43 | verifyEmail = true 44 | } else { 45 | return 46 | } 47 | } 48 | var authResponse *api.AuthorizationResponse 49 | var keyEncKey []byte 50 | if verifyEmail || srpAttr.IsEmailMFAEnabled { 51 | authResponse, flowErr = c.validateEmail(cxt, email) 52 | } else { 53 | authResponse, keyEncKey, flowErr = c.signInViaPassword(cxt, srpAttr) 54 | } 55 | if flowErr != nil { 56 | return 57 | } 58 | if authResponse.IsMFARequired() { 59 | authResponse, flowErr = c.validateTOTP(cxt, authResponse) 60 | } 61 | if authResponse.EncryptedToken == "" || authResponse.KeyAttributes == nil { 62 | panic("no encrypted token or keyAttributes") 63 | } 64 | secretInfo, decErr := c.decryptAccSecretInfo(cxt, authResponse, keyEncKey) 65 | if decErr != nil { 66 | flowErr = decErr 67 | return 68 | } 69 | 70 | err := c.storeAccount(cxt, email, authResponse.ID, app, secretInfo, dir) 71 | if err != nil { 72 | flowErr = err 73 | return 74 | } else { 75 | fmt.Println("Account added successfully") 76 | fmt.Println("run `ente export` to initiate export of your account data") 77 | } 78 | } 79 | 80 | func (c *ClICtrl) storeAccount(_ context.Context, email string, userID int64, app api.App, secretInfo *model.AccSecretInfo, exportDir string) error { 81 | // get password 82 | err := c.DB.Update(func(tx *bolt.Tx) error { 83 | b, err := tx.CreateBucketIfNotExists([]byte(AccBucket)) 84 | if err != nil { 85 | return err 86 | } 87 | accInfo := model.Account{ 88 | Email: email, 89 | UserID: userID, 90 | MasterKey: *model.MakeEncString(secretInfo.MasterKey, c.KeyHolder.DeviceKey), 91 | SecretKey: *model.MakeEncString(secretInfo.SecretKey, c.KeyHolder.DeviceKey), 92 | Token: *model.MakeEncString(secretInfo.Token, c.KeyHolder.DeviceKey), 93 | App: app, 94 | PublicKey: encoding.EncodeBase64(secretInfo.PublicKey), 95 | ExportDir: exportDir, 96 | } 97 | accInfoBytes, err := json.Marshal(accInfo) 98 | if err != nil { 99 | return err 100 | } 101 | accountKey := accInfo.AccountKey() 102 | return b.Put([]byte(accountKey), accInfoBytes) 103 | }) 104 | return err 105 | } 106 | 107 | func (c *ClICtrl) GetAccounts(cxt context.Context) ([]model.Account, error) { 108 | var accounts []model.Account 109 | err := c.DB.View(func(tx *bolt.Tx) error { 110 | b := tx.Bucket([]byte(AccBucket)) 111 | err := b.ForEach(func(k, v []byte) error { 112 | var info model.Account 113 | err := json.Unmarshal(v, &info) 114 | if err != nil { 115 | return err 116 | } 117 | accounts = append(accounts, info) 118 | return nil 119 | }) 120 | if err != nil { 121 | return err 122 | } 123 | return nil 124 | }) 125 | return accounts, err 126 | } 127 | 128 | func (c *ClICtrl) ListAccounts(cxt context.Context) error { 129 | accounts, err := c.GetAccounts(cxt) 130 | if err != nil { 131 | return err 132 | } 133 | fmt.Printf("Configured accounts: %d\n", len(accounts)) 134 | for _, acc := range accounts { 135 | fmt.Println("====================================") 136 | fmt.Println("Email: ", acc.Email) 137 | fmt.Println("ID: ", acc.UserID) 138 | fmt.Println("App: ", acc.App) 139 | fmt.Println("ExportDir:", acc.ExportDir) 140 | fmt.Println("====================================") 141 | } 142 | return nil 143 | } 144 | 145 | func (c *ClICtrl) UpdateAccount(ctx context.Context, params model.UpdateAccountParams) error { 146 | accounts, err := c.GetAccounts(ctx) 147 | if err != nil { 148 | return err 149 | } 150 | var acc *model.Account 151 | for _, a := range accounts { 152 | if a.Email == params.Email && a.App == params.App { 153 | acc = &a 154 | break 155 | } 156 | } 157 | if acc == nil { 158 | return fmt.Errorf("account not found, use `account list` to list accounts") 159 | } 160 | if params.ExportDir != nil && *params.ExportDir != "" { 161 | _, err := internal.ValidateDirForWrite(*params.ExportDir) 162 | if err != nil { 163 | return err 164 | } 165 | acc.ExportDir = *params.ExportDir 166 | } 167 | err = c.DB.Update(func(tx *bolt.Tx) error { 168 | b, err := tx.CreateBucketIfNotExists([]byte(AccBucket)) 169 | if err != nil { 170 | return err 171 | } 172 | accInfoBytes, err := json.Marshal(acc) 173 | if err != nil { 174 | return err 175 | } 176 | accountKey := acc.AccountKey() 177 | return b.Put([]byte(accountKey), accInfoBytes) 178 | }) 179 | return err 180 | 181 | } 182 | -------------------------------------------------------------------------------- /pkg/bolt_store.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/ente-io/cli/pkg/model" 7 | "github.com/ente-io/cli/utils/encoding" 8 | ) 9 | 10 | func boltAEKey(entry *model.AlbumFileEntry) []byte { 11 | return []byte(fmt.Sprintf("%d:%d", entry.AlbumID, entry.FileID)) 12 | } 13 | 14 | func (c *ClICtrl) DeleteAlbumEntry(ctx context.Context, entry *model.AlbumFileEntry) error { 15 | return c.DeleteValue(ctx, model.RemoteAlbumEntries, boltAEKey(entry)) 16 | } 17 | 18 | func (c *ClICtrl) UpsertAlbumEntry(ctx context.Context, entry *model.AlbumFileEntry) error { 19 | return c.PutValue(ctx, model.RemoteAlbumEntries, boltAEKey(entry), encoding.MustMarshalJSON(entry)) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/cli.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ente-io/cli/internal/api" 6 | "github.com/ente-io/cli/pkg/secrets" 7 | bolt "go.etcd.io/bbolt" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | type ClICtrl struct { 13 | Client *api.Client 14 | DB *bolt.DB 15 | KeyHolder *secrets.KeyHolder 16 | tempFolder string 17 | } 18 | 19 | func (c *ClICtrl) Init() error { 20 | tempPath := filepath.Join(os.TempDir(), "ente-download") 21 | // create temp folder if not exists 22 | if _, err := os.Stat(tempPath); os.IsNotExist(err) { 23 | err = os.Mkdir(tempPath, 0755) 24 | if err != nil { 25 | return err 26 | } 27 | } 28 | c.tempFolder = tempPath 29 | return c.DB.Update(func(tx *bolt.Tx) error { 30 | _, err := tx.CreateBucketIfNotExists([]byte(AccBucket)) 31 | if err != nil { 32 | return fmt.Errorf("create bucket: %s", err) 33 | } 34 | return nil 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/disk.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "github.com/ente-io/cli/pkg/model" 8 | "github.com/ente-io/cli/pkg/model/export" 9 | "io" 10 | "os" 11 | "strings" 12 | ) 13 | 14 | const ( 15 | albumMetaFile = "album_meta.json" 16 | albumMetaFolder = ".meta" 17 | ) 18 | 19 | type albumDiskInfo struct { 20 | ExportRoot string 21 | AlbumMeta *export.AlbumMetadata 22 | // FileNames contain the name of the files at root level of the album folder 23 | FileNames *map[string]bool 24 | MetaFileNameToDiskFileMap *map[string]*export.DiskFileMetadata 25 | FileIdToDiskFileMap *map[int64]*export.DiskFileMetadata 26 | } 27 | 28 | func (a *albumDiskInfo) IsFilePresent(file model.RemoteFile) bool { 29 | // check if file.ID is present 30 | _, ok := (*a.FileIdToDiskFileMap)[file.ID] 31 | return ok 32 | } 33 | 34 | func (a *albumDiskInfo) IsFileNamePresent(fileName string) bool { 35 | _, ok := (*a.FileNames)[strings.ToLower(fileName)] 36 | return ok 37 | } 38 | 39 | func (a *albumDiskInfo) AddEntry(metadata *export.DiskFileMetadata) error { 40 | if _, ok := (*a.FileIdToDiskFileMap)[metadata.Info.ID]; ok { 41 | return errors.New("fileID already present") 42 | } 43 | if _, ok := (*a.MetaFileNameToDiskFileMap)[strings.ToLower(metadata.MetaFileName)]; ok { 44 | return errors.New("fileName already present") 45 | } 46 | (*a.MetaFileNameToDiskFileMap)[strings.ToLower(metadata.MetaFileName)] = metadata 47 | (*a.FileIdToDiskFileMap)[metadata.Info.ID] = metadata 48 | for _, filename := range metadata.Info.FileNames { 49 | if _, ok := (*a.FileNames)[strings.ToLower(filename)]; ok { 50 | return errors.New("fileName already present") 51 | } 52 | (*a.FileNames)[strings.ToLower(filename)] = true 53 | } 54 | return nil 55 | } 56 | 57 | func (a *albumDiskInfo) RemoveEntry(metadata *export.DiskFileMetadata) error { 58 | if _, ok := (*a.FileIdToDiskFileMap)[metadata.Info.ID]; !ok { 59 | return errors.New("fileID not present") 60 | } 61 | if _, ok := (*a.MetaFileNameToDiskFileMap)[strings.ToLower(metadata.MetaFileName)]; !ok { 62 | return errors.New("fileName not present") 63 | } 64 | delete(*a.MetaFileNameToDiskFileMap, strings.ToLower(metadata.MetaFileName)) 65 | delete(*a.FileIdToDiskFileMap, metadata.Info.ID) 66 | for _, filename := range metadata.Info.FileNames { 67 | delete(*a.FileNames, strings.ToLower(filename)) 68 | } 69 | return nil 70 | } 71 | 72 | func (a *albumDiskInfo) IsMetaFileNamePresent(metaFileName string) bool { 73 | _, ok := (*a.MetaFileNameToDiskFileMap)[strings.ToLower(metaFileName)] 74 | return ok 75 | } 76 | 77 | // GenerateUniqueMetaFileName generates a unique metafile name. 78 | func (a *albumDiskInfo) GenerateUniqueMetaFileName(baseFileName, extension string) string { 79 | potentialDiskFileName := fmt.Sprintf("%s%s.json", baseFileName, extension) 80 | count := 1 81 | for a.IsMetaFileNamePresent(potentialDiskFileName) { 82 | // separate the file name and extension 83 | fileName := fmt.Sprintf("%s_%d", baseFileName, count) 84 | potentialDiskFileName = fmt.Sprintf("%s%s.json", fileName, extension) 85 | count++ 86 | if !a.IsMetaFileNamePresent(potentialDiskFileName) { 87 | break 88 | } 89 | } 90 | return potentialDiskFileName 91 | } 92 | 93 | // GenerateUniqueFileName generates a unique file name. 94 | func (a *albumDiskInfo) GenerateUniqueFileName(baseFileName, extension string) string { 95 | fileName := fmt.Sprintf("%s%s", baseFileName, extension) 96 | count := 1 97 | for a.IsFileNamePresent(strings.ToLower(fileName)) { 98 | // separate the file name and extension 99 | fileName = fmt.Sprintf("%s_%d%s", baseFileName, count, extension) 100 | count++ 101 | if !a.IsFileNamePresent(strings.ToLower(fileName)) { 102 | break 103 | } 104 | } 105 | return fileName 106 | } 107 | 108 | func (a *albumDiskInfo) GetDiskFileMetadata(file model.RemoteFile) *export.DiskFileMetadata { 109 | // check if file.ID is present 110 | diskFile, ok := (*a.FileIdToDiskFileMap)[file.ID] 111 | if !ok { 112 | return nil 113 | } 114 | return diskFile 115 | } 116 | 117 | func writeJSONToFile(filePath string, data interface{}) error { 118 | file, err := os.Create(filePath) 119 | if err != nil { 120 | return err 121 | } 122 | defer file.Close() 123 | 124 | encoder := json.NewEncoder(file) 125 | encoder.SetIndent("", " ") 126 | return encoder.Encode(data) 127 | } 128 | 129 | func readJSONFromFile(filePath string, data interface{}) error { 130 | file, err := os.Open(filePath) 131 | if err != nil { 132 | return err 133 | } 134 | defer file.Close() 135 | 136 | decoder := json.NewDecoder(file) 137 | return decoder.Decode(data) 138 | } 139 | 140 | func Move(source, destination string) error { 141 | err := os.Rename(source, destination) 142 | if err != nil { 143 | return moveCrossDevice(source, destination) 144 | } 145 | return err 146 | } 147 | 148 | func moveCrossDevice(source, destination string) error { 149 | src, err := os.Open(source) 150 | if err != nil { 151 | return err 152 | } 153 | dst, err := os.Create(destination) 154 | if err != nil { 155 | src.Close() 156 | return err 157 | } 158 | _, err = io.Copy(dst, src) 159 | src.Close() 160 | dst.Close() 161 | if err != nil { 162 | return err 163 | } 164 | fi, err := os.Stat(source) 165 | if err != nil { 166 | os.Remove(destination) 167 | return err 168 | } 169 | err = os.Chmod(destination, fi.Mode()) 170 | if err != nil { 171 | os.Remove(destination) 172 | return err 173 | } 174 | os.Remove(source) 175 | return nil 176 | } 177 | -------------------------------------------------------------------------------- /pkg/disk_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestGenerateUniqueFileName(t *testing.T) { 10 | existingFilenames := make(map[string]bool) 11 | testFilename := "FullSizeRender.jpg" // what Apple calls shared files 12 | 13 | existingFilenames[strings.ToLower(testFilename)] = true 14 | 15 | a := &albumDiskInfo{ 16 | FileNames: &existingFilenames, 17 | } 18 | 19 | // this is taken from downloadEntry() 20 | extension := filepath.Ext(testFilename) 21 | baseFileName := strings.TrimSuffix(filepath.Clean(filepath.Base(testFilename)), extension) 22 | 23 | for i := 0; i < 100; i++ { 24 | newFilename := a.GenerateUniqueFileName(baseFileName, extension) 25 | if strings.Contains(newFilename, "_1_2") { 26 | t.Fatalf("Filename contained _1_2") 27 | } else { 28 | // add generated name to existing files 29 | existingFilenames[strings.ToLower(newFilename)] = true 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkg/download.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "archive/zip" 5 | "context" 6 | "fmt" 7 | "github.com/ente-io/cli/internal/crypto" 8 | "github.com/ente-io/cli/pkg/model" 9 | "github.com/ente-io/cli/utils" 10 | "github.com/ente-io/cli/utils/encoding" 11 | "io" 12 | "log" 13 | "os" 14 | "path/filepath" 15 | "strings" 16 | ) 17 | 18 | func (c *ClICtrl) downloadAndDecrypt( 19 | ctx context.Context, 20 | file model.RemoteFile, 21 | deviceKey []byte, 22 | ) (*string, error) { 23 | dir := c.tempFolder 24 | downloadPath := fmt.Sprintf("%s/%d", dir, file.ID) 25 | // check if file exists 26 | if stat, err := os.Stat(downloadPath); err == nil && stat.Size() == file.Info.FileSize { 27 | log.Printf("File already exists %s (%s)", file.GetTitle(), utils.ByteCountDecimal(file.Info.FileSize)) 28 | } else { 29 | log.Printf("Downloading %s (%s)", file.GetTitle(), utils.ByteCountDecimal(file.Info.FileSize)) 30 | err := c.Client.DownloadFile(ctx, file.ID, downloadPath) 31 | if err != nil { 32 | return nil, fmt.Errorf("error downloading file %d: %w", file.ID, err) 33 | } 34 | } 35 | decryptedPath := fmt.Sprintf("%s/%d.decrypted", dir, file.ID) 36 | err := crypto.DecryptFile(downloadPath, decryptedPath, file.Key.MustDecrypt(deviceKey), encoding.DecodeBase64(file.FileNonce)) 37 | if err != nil { 38 | log.Printf("Error decrypting file %d: %s", file.ID, err) 39 | return nil, model.ErrDecryption 40 | } else { 41 | _ = os.Remove(downloadPath) 42 | } 43 | return &decryptedPath, nil 44 | } 45 | 46 | func UnpackLive(src string) (imagePath, videoPath string, retErr error) { 47 | var filenames []string 48 | reader, err := zip.OpenReader(src) 49 | if err != nil { 50 | retErr = err 51 | return 52 | } 53 | defer reader.Close() 54 | 55 | dest := filepath.Dir(src) 56 | 57 | for _, file := range reader.File { 58 | destFilePath := filepath.Join(dest, file.Name) 59 | filenames = append(filenames, destFilePath) 60 | 61 | destDir := filepath.Dir(destFilePath) 62 | if err := os.MkdirAll(destDir, 0755); err != nil { 63 | retErr = err 64 | return 65 | } 66 | 67 | destFile, err := os.Create(destFilePath) 68 | if err != nil { 69 | retErr = err 70 | return 71 | } 72 | defer destFile.Close() 73 | 74 | srcFile, err := file.Open() 75 | if err != nil { 76 | retErr = err 77 | return 78 | } 79 | defer srcFile.Close() 80 | 81 | _, err = io.Copy(destFile, srcFile) 82 | if err != nil { 83 | retErr = err 84 | return 85 | } 86 | } 87 | for _, filepath := range filenames { 88 | if strings.Contains(strings.ToLower(filepath), "image") { 89 | imagePath = filepath 90 | } else if strings.Contains(strings.ToLower(filepath), "video") { 91 | videoPath = filepath 92 | } else { 93 | retErr = fmt.Errorf("unexpcted file in zip %s", filepath) 94 | } 95 | } 96 | return 97 | } 98 | -------------------------------------------------------------------------------- /pkg/mapper/photo.go: -------------------------------------------------------------------------------- 1 | package mapper 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "github.com/ente-io/cli/internal/api" 8 | eCrypto "github.com/ente-io/cli/internal/crypto" 9 | "github.com/ente-io/cli/pkg/model" 10 | "github.com/ente-io/cli/pkg/model/export" 11 | "github.com/ente-io/cli/pkg/secrets" 12 | "github.com/ente-io/cli/utils/encoding" 13 | "log" 14 | ) 15 | 16 | func MapCollectionToAlbum(ctx context.Context, collection api.Collection, holder *secrets.KeyHolder) (*model.RemoteAlbum, error) { 17 | var album model.RemoteAlbum 18 | userID := ctx.Value("user_id").(int64) 19 | album.OwnerID = collection.Owner.ID 20 | album.ID = collection.ID 21 | album.IsShared = collection.Owner.ID != userID 22 | album.LastUpdatedAt = collection.UpdationTime 23 | album.IsDeleted = collection.IsDeleted 24 | collectionKey, err := holder.GetCollectionKey(ctx, collection) 25 | if err != nil { 26 | return nil, err 27 | } 28 | album.AlbumKey = *model.MakeEncString(collectionKey, holder.DeviceKey) 29 | var name string 30 | if collection.EncryptedName != "" { 31 | decrName, err := eCrypto.SecretBoxOpenBase64(collection.EncryptedName, collection.NameDecryptionNonce, collectionKey) 32 | if err != nil { 33 | log.Fatalf("failed to decrypt collection name: %v", err) 34 | } 35 | name = string(decrName) 36 | } else { 37 | // Early beta users (friends & family) might have collections without encrypted names 38 | name = collection.Name 39 | } 40 | album.AlbumName = name 41 | if collection.MagicMetadata != nil { 42 | _, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(collection.MagicMetadata.Data, collectionKey, collection.MagicMetadata.Header) 43 | if err != nil { 44 | return nil, err 45 | } 46 | err = json.Unmarshal(encodedJsonBytes, &album.PrivateMeta) 47 | if err != nil { 48 | return nil, err 49 | } 50 | } 51 | if collection.PublicMagicMetadata != nil { 52 | _, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(collection.PublicMagicMetadata.Data, collectionKey, collection.PublicMagicMetadata.Header) 53 | if err != nil { 54 | return nil, err 55 | } 56 | err = json.Unmarshal(encodedJsonBytes, &album.PublicMeta) 57 | if err != nil { 58 | return nil, err 59 | } 60 | } 61 | if album.IsShared && collection.SharedMagicMetadata != nil { 62 | _, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(collection.SharedMagicMetadata.Data, collectionKey, collection.SharedMagicMetadata.Header) 63 | if err != nil { 64 | return nil, err 65 | } 66 | err = json.Unmarshal(encodedJsonBytes, &album.SharedMeta) 67 | if err != nil { 68 | return nil, err 69 | } 70 | } 71 | return &album, nil 72 | } 73 | 74 | func MapApiFileToPhotoFile(ctx context.Context, album model.RemoteAlbum, file api.File, holder *secrets.KeyHolder) (*model.RemoteFile, error) { 75 | if file.IsDeleted { 76 | return nil, errors.New("file is deleted") 77 | } 78 | albumKey := album.AlbumKey.MustDecrypt(holder.DeviceKey) 79 | fileKey, err := eCrypto.SecretBoxOpen( 80 | encoding.DecodeBase64(file.EncryptedKey), 81 | encoding.DecodeBase64(file.KeyDecryptionNonce), 82 | albumKey) 83 | if err != nil { 84 | return nil, err 85 | } 86 | var photoFile model.RemoteFile 87 | photoFile.ID = file.ID 88 | photoFile.LastUpdateTime = file.UpdationTime 89 | photoFile.Key = *model.MakeEncString(fileKey, holder.DeviceKey) 90 | photoFile.FileNonce = file.File.DecryptionHeader 91 | photoFile.ThumbnailNonce = file.Thumbnail.DecryptionHeader 92 | photoFile.OwnerID = file.OwnerID 93 | if file.Info != nil { 94 | photoFile.Info = model.Info{ 95 | FileSize: file.Info.FileSize, 96 | ThumbnailSize: file.Info.ThumbnailSize, 97 | } 98 | } 99 | if file.Metadata.DecryptionHeader != "" { 100 | _, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(file.Metadata.EncryptedData, fileKey, file.Metadata.DecryptionHeader) 101 | if err != nil { 102 | return nil, err 103 | } 104 | err = json.Unmarshal(encodedJsonBytes, &photoFile.Metadata) 105 | if err != nil { 106 | return nil, err 107 | } 108 | } 109 | if file.MagicMetadata != nil { 110 | _, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(file.MagicMetadata.Data, fileKey, file.MagicMetadata.Header) 111 | if err != nil { 112 | return nil, err 113 | } 114 | err = json.Unmarshal(encodedJsonBytes, &photoFile.PrivateMetadata) 115 | if err != nil { 116 | return nil, err 117 | } 118 | } 119 | if file.PubicMagicMetadata != nil { 120 | _, encodedJsonBytes, err := eCrypto.DecryptChaChaBase64(file.PubicMagicMetadata.Data, fileKey, file.PubicMagicMetadata.Header) 121 | if err != nil { 122 | return nil, err 123 | } 124 | err = json.Unmarshal(encodedJsonBytes, &photoFile.PublicMetadata) 125 | if err != nil { 126 | return nil, err 127 | } 128 | } 129 | return &photoFile, nil 130 | } 131 | 132 | func MapRemoteFileToDiskMetadata(file model.RemoteFile) *export.DiskFileMetadata { 133 | return &export.DiskFileMetadata{ 134 | Title: file.GetTitle(), 135 | Description: file.GetCaption(), 136 | CreationTime: file.GetCreationTime(), 137 | ModificationTime: file.GetModificationTime(), 138 | Location: file.GetLatlong(), 139 | Info: &export.Info{ 140 | ID: file.ID, 141 | Hash: file.GetFileHash(), 142 | OwnerID: file.OwnerID, 143 | }, 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /pkg/model/account.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ente-io/cli/internal/api" 6 | ) 7 | 8 | type Account struct { 9 | Email string `json:"email" binding:"required"` 10 | UserID int64 `json:"userID" binding:"required"` 11 | App api.App `json:"app" binding:"required"` 12 | MasterKey EncString `json:"masterKey" binding:"required"` 13 | SecretKey EncString `json:"secretKey" binding:"required"` 14 | // PublicKey corresponding to the secret key 15 | PublicKey string `json:"publicKey" binding:"required"` 16 | Token EncString `json:"token" binding:"required"` 17 | ExportDir string `json:"exportDir"` 18 | } 19 | 20 | type UpdateAccountParams struct { 21 | Email string 22 | App api.App 23 | ExportDir *string 24 | } 25 | 26 | func (a *Account) AccountKey() string { 27 | return fmt.Sprintf("%s-%d", a.App, a.UserID) 28 | } 29 | 30 | func (a *Account) DataBucket() string { 31 | return fmt.Sprintf("%s-%d-data", a.App, a.UserID) 32 | } 33 | 34 | type AccSecretInfo struct { 35 | MasterKey []byte 36 | SecretKey []byte 37 | Token []byte 38 | PublicKey []byte 39 | } 40 | -------------------------------------------------------------------------------- /pkg/model/constants.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type PhotosStore string 4 | 5 | const ( 6 | KVConfig PhotosStore = "kvConfig" 7 | RemoteAlbums PhotosStore = "remoteAlbums" 8 | RemoteFiles PhotosStore = "remoteFiles" 9 | RemoteAlbumEntries PhotosStore = "remoteAlbumEntries" 10 | ) 11 | 12 | const ( 13 | CollectionsSyncKey = "lastCollectionSync" 14 | CollectionsFileSyncKeyFmt = "collectionFilesSync-%d" 15 | ) 16 | -------------------------------------------------------------------------------- /pkg/model/enc_string.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/ente-io/cli/internal/crypto" 5 | "github.com/ente-io/cli/utils/encoding" 6 | "log" 7 | ) 8 | 9 | type EncString struct { 10 | CipherText string `json:"cipherText"` 11 | Nonce string `json:"nonce"` 12 | } 13 | 14 | func MakeEncString(plainTextBytes []byte, key []byte) *EncString { 15 | cipher, nonce, err := crypto.EncryptChaCha20poly1305(plainTextBytes, key) 16 | if err != nil { 17 | log.Fatalf("failed to encrypt %s", err) 18 | } 19 | return &EncString{ 20 | CipherText: encoding.EncodeBase64(cipher), 21 | Nonce: encoding.EncodeBase64(nonce), 22 | } 23 | } 24 | 25 | func (e *EncString) MustDecrypt(key []byte) []byte { 26 | _, plainBytes, err := crypto.DecryptChaChaBase64(e.CipherText, key, e.Nonce) 27 | if err != nil { 28 | panic(err) 29 | } 30 | return plainBytes 31 | } 32 | -------------------------------------------------------------------------------- /pkg/model/enc_string_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "crypto/rand" 5 | "testing" 6 | ) 7 | 8 | func TestEncString(t *testing.T) { 9 | key := make([]byte, 32) 10 | _, err := rand.Read(key) 11 | if err != nil { 12 | t.Fatalf("error generating key: %v", err) 13 | } 14 | data := "dataToEncrypt" 15 | encData := MakeEncString([]byte(data), key) 16 | decryptedData := encData.MustDecrypt(key) 17 | if string(decryptedData) != data { 18 | t.Fatalf("decrypted data is not equal to original data") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/model/errors.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | var ErrDecryption = errors.New("error while decrypting the file") 9 | var ErrLiveZip = errors.New("error: no image or video file found in zip") 10 | 11 | func ShouldRetrySync(err error) bool { 12 | return strings.Contains(err.Error(), "read tcp") || 13 | strings.Contains(err.Error(), "dial tcp") 14 | } 15 | -------------------------------------------------------------------------------- /pkg/model/export/location.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | type Location struct { 4 | Latitude float64 `json:"latitude"` 5 | Longitude float64 `json:"longitude"` 6 | } 7 | -------------------------------------------------------------------------------- /pkg/model/export/metadata.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import "time" 4 | 5 | type AlbumMetadata struct { 6 | ID int64 `json:"id"` 7 | OwnerID int64 `json:"ownerID"` 8 | AlbumName string `json:"albumName"` 9 | IsDeleted bool `json:"isDeleted"` 10 | // This is to handle the case where two accounts are exporting to the same directory 11 | // and a album is shared between them 12 | AccountOwnerIDs []int64 `json:"accountOwnerIDs"` 13 | 14 | // Folder name is the name of the disk folder that contains the album data 15 | // exclude this from json serialization 16 | FolderName string `json:"-"` 17 | } 18 | 19 | // AddAccountOwner adds the given account id to the list of account owners 20 | // if it is not already present. Returns true if the account id was added 21 | // and false otherwise 22 | func (a *AlbumMetadata) AddAccountOwner(id int64) bool { 23 | for _, ownerID := range a.AccountOwnerIDs { 24 | if ownerID == id { 25 | return false 26 | } 27 | } 28 | a.AccountOwnerIDs = append(a.AccountOwnerIDs, id) 29 | return true 30 | } 31 | 32 | // DiskFileMetadata is the metadata for a file when exported to disk 33 | // For S3 compliant storage, we will introduce a new struct that will contain references to the albums 34 | type DiskFileMetadata struct { 35 | Title string `json:"title"` 36 | Description *string `json:"description"` 37 | Location *Location `json:"location"` 38 | CreationTime time.Time `json:"creationTime"` 39 | ModificationTime time.Time `json:"modificationTime"` 40 | Info *Info `json:"info"` 41 | 42 | // exclude this from json serialization 43 | MetaFileName string `json:"-"` 44 | } 45 | 46 | func (d *DiskFileMetadata) AddFileName(fileName string) { 47 | if d.Info.FileNames == nil { 48 | d.Info.FileNames = make([]string, 0) 49 | } 50 | for _, ownerID := range d.Info.FileNames { 51 | if ownerID == fileName { 52 | return 53 | } 54 | } 55 | d.Info.FileNames = append(d.Info.FileNames, fileName) 56 | } 57 | 58 | type Info struct { 59 | ID int64 `json:"id"` 60 | Hash *string `json:"hash"` 61 | OwnerID int64 `json:"ownerID"` 62 | // A file can contain multiple parts (example: live photos or burst photos) 63 | FileNames []string `json:"fileNames"` 64 | } 65 | -------------------------------------------------------------------------------- /pkg/model/remote.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ente-io/cli/pkg/model/export" 6 | "sort" 7 | "time" 8 | ) 9 | 10 | type FileType int8 11 | 12 | const ( 13 | Image FileType = iota 14 | Video 15 | LivePhoto 16 | Unknown = 127 17 | ) 18 | 19 | type RemoteFile struct { 20 | ID int64 `json:"id"` 21 | OwnerID int64 `json:"ownerID"` 22 | Key EncString `json:"key"` 23 | LastUpdateTime int64 `json:"lastUpdateTime"` 24 | FileNonce string `json:"fileNonce"` 25 | ThumbnailNonce string `json:"thumbnailNonce"` 26 | Metadata map[string]interface{} `json:"metadata"` 27 | PrivateMetadata map[string]interface{} `json:"privateMetadata"` 28 | PublicMetadata map[string]interface{} `json:"publicMetadata"` 29 | Info Info `json:"info"` 30 | } 31 | 32 | type Info struct { 33 | FileSize int64 `json:"fileSize,omitempty"` 34 | ThumbnailSize int64 `json:"thumbSize,omitempty"` 35 | } 36 | 37 | type RemoteAlbum struct { 38 | ID int64 `json:"id"` 39 | OwnerID int64 `json:"ownerID"` 40 | IsShared bool `json:"isShared"` 41 | IsDeleted bool `json:"isDeleted"` 42 | AlbumName string `json:"albumName"` 43 | AlbumKey EncString `json:"albumKey"` 44 | PublicMeta map[string]interface{} `json:"publicMeta"` 45 | PrivateMeta map[string]interface{} `json:"privateMeta"` 46 | SharedMeta map[string]interface{} `json:"sharedMeta"` 47 | LastUpdatedAt int64 `json:"lastUpdatedAt"` 48 | } 49 | 50 | type AlbumFileEntry struct { 51 | FileID int64 `json:"fileID"` 52 | AlbumID int64 `json:"albumID"` 53 | IsDeleted bool `json:"isDeleted"` 54 | SyncedLocally bool `json:"localSync"` 55 | } 56 | 57 | // SortAlbumFileEntry sorts the given entries by isDeleted and then by albumID 58 | func SortAlbumFileEntry(entries []*AlbumFileEntry) { 59 | sort.Slice(entries, func(i, j int) bool { 60 | if entries[i].IsDeleted != entries[j].IsDeleted { 61 | return !entries[i].IsDeleted && entries[j].IsDeleted 62 | } 63 | return entries[i].AlbumID < entries[j].AlbumID 64 | }) 65 | } 66 | 67 | func (r *RemoteFile) GetFileType() FileType { 68 | value, ok := r.Metadata["fileType"] 69 | if !ok { 70 | panic("fileType not found in metadata") 71 | } 72 | switch int8(value.(float64)) { 73 | case 0: 74 | return Image 75 | case 1: 76 | return Video 77 | case 2: 78 | return LivePhoto 79 | } 80 | panic(fmt.Sprintf("invalid fileType %d", value.(int8))) 81 | } 82 | 83 | func (r *RemoteFile) IsLivePhoto() bool { 84 | return r.GetFileType() == LivePhoto 85 | } 86 | 87 | func (r *RemoteFile) GetFileHash() *string { 88 | value, ok := r.Metadata["hash"] 89 | if !ok { 90 | if r.IsLivePhoto() { 91 | imageHash, hasImgHash := r.Metadata["imageHash"] 92 | vidHash, hasVidHash := r.Metadata["videoHash"] 93 | if hasImgHash && hasVidHash { 94 | hash := fmt.Sprintf("%s:%s", imageHash, vidHash) 95 | return &hash 96 | } 97 | } 98 | return nil 99 | } 100 | if str, ok := value.(string); ok { 101 | return &str 102 | } 103 | return nil 104 | } 105 | 106 | func (r *RemoteFile) GetTitle() string { 107 | if r.PublicMetadata != nil { 108 | if value, ok := r.PublicMetadata["editedName"]; ok { 109 | return value.(string) 110 | } 111 | } 112 | value, ok := r.Metadata["title"] 113 | if !ok { 114 | panic("title not found in metadata") 115 | } 116 | return value.(string) 117 | } 118 | 119 | func (r *RemoteFile) GetCaption() *string { 120 | if r.PublicMetadata != nil { 121 | if value, ok := r.PublicMetadata["caption"]; ok { 122 | if str, ok := value.(string); ok { 123 | return &str 124 | } 125 | } 126 | } 127 | return nil 128 | } 129 | 130 | func (r *RemoteFile) GetCreationTime() time.Time { 131 | 132 | if r.PublicMetadata != nil { 133 | if value, ok := r.PublicMetadata["editedTime"]; ok && value.(float64) != 0 { 134 | return time.UnixMicro(int64(value.(float64))) 135 | } 136 | } 137 | value, ok := r.Metadata["creationTime"] 138 | if !ok { 139 | panic("creationTime not found in metadata") 140 | } 141 | return time.UnixMicro(int64(value.(float64))) 142 | } 143 | 144 | func (r *RemoteFile) GetModificationTime() time.Time { 145 | value, ok := r.Metadata["modificationTime"] 146 | if !ok { 147 | panic("creationTime not found in metadata") 148 | } 149 | return time.UnixMicro(int64(value.(float64))) 150 | } 151 | 152 | func (r *RemoteFile) GetLatlong() *export.Location { 153 | if r.PublicMetadata != nil { 154 | // check if lat and long key exists 155 | if lat, ok := r.PublicMetadata["lat"]; ok { 156 | if long, ok := r.PublicMetadata["long"]; ok { 157 | if lat.(float64) == 0 && long.(float64) == 0 { 158 | return nil 159 | } 160 | return &export.Location{ 161 | Latitude: lat.(float64), 162 | Longitude: long.(float64), 163 | } 164 | } 165 | } 166 | } 167 | if lat, ok := r.Metadata["latitude"]; ok && lat != nil { 168 | if long, ok2 := r.Metadata["longitude"]; ok2 && long != nil { 169 | return &export.Location{ 170 | Latitude: lat.(float64), 171 | Longitude: long.(float64), 172 | } 173 | } 174 | } 175 | return nil 176 | } 177 | -------------------------------------------------------------------------------- /pkg/remote_sync.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/ente-io/cli/pkg/mapper" 8 | "github.com/ente-io/cli/pkg/model" 9 | "github.com/ente-io/cli/utils/encoding" 10 | "log" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | func (c *ClICtrl) fetchRemoteCollections(ctx context.Context) error { 16 | lastSyncTime, err2 := c.GetInt64ConfigValue(ctx, model.CollectionsSyncKey) 17 | if err2 != nil { 18 | return err2 19 | } 20 | collections, err := c.Client.GetCollections(ctx, lastSyncTime) 21 | if err != nil { 22 | return fmt.Errorf("failed to get collections: %s", err) 23 | } 24 | maxUpdated := lastSyncTime 25 | for _, collection := range collections { 26 | if lastSyncTime == 0 && collection.IsDeleted { 27 | continue 28 | } 29 | album, mapErr := mapper.MapCollectionToAlbum(ctx, collection, c.KeyHolder) 30 | if mapErr != nil { 31 | return mapErr 32 | } 33 | if album.LastUpdatedAt > maxUpdated { 34 | maxUpdated = album.LastUpdatedAt 35 | } 36 | albumJson := encoding.MustMarshalJSON(album) 37 | putErr := c.PutValue(ctx, model.RemoteAlbums, []byte(strconv.FormatInt(album.ID, 10)), albumJson) 38 | if putErr != nil { 39 | return putErr 40 | } 41 | } 42 | if maxUpdated > lastSyncTime { 43 | err = c.PutConfigValue(ctx, model.CollectionsSyncKey, []byte(strconv.FormatInt(maxUpdated, 10))) 44 | if err != nil { 45 | return fmt.Errorf("failed to update last sync time: %s", err) 46 | } 47 | } 48 | return nil 49 | } 50 | 51 | func (c *ClICtrl) fetchRemoteFiles(ctx context.Context) error { 52 | albums, err := c.getRemoteAlbums(ctx) 53 | if err != nil { 54 | return err 55 | } 56 | for _, album := range albums { 57 | if album.IsDeleted { 58 | continue 59 | } 60 | 61 | lastSyncTime, lastSyncTimeErr := c.GetInt64ConfigValue(ctx, fmt.Sprintf(model.CollectionsFileSyncKeyFmt, album.ID)) 62 | if lastSyncTimeErr != nil { 63 | return lastSyncTimeErr 64 | } 65 | 66 | isFirstSync := lastSyncTime == 0 67 | 68 | for { 69 | if lastSyncTime == album.LastUpdatedAt { 70 | break 71 | } 72 | if isFirstSync { 73 | log.Printf("Sync files metadata for album %s\n", album.AlbumName) 74 | } else { 75 | log.Printf("Sync files metadata for album %s\n from %s", album.AlbumName, time.UnixMicro(lastSyncTime)) 76 | } 77 | if !isFirstSync { 78 | t := time.UnixMicro(lastSyncTime) 79 | log.Printf("Fetching files metadata for album %s from %v\n", album.AlbumName, t) 80 | } 81 | files, hasMore, err := c.Client.GetFiles(ctx, album.ID, lastSyncTime) 82 | if err != nil { 83 | return err 84 | } 85 | maxUpdated := lastSyncTime 86 | for _, file := range files { 87 | if file.UpdationTime > maxUpdated { 88 | maxUpdated = file.UpdationTime 89 | } 90 | if isFirstSync && file.IsDeleted { 91 | // on first sync, no need to sync delete markers 92 | continue 93 | } 94 | albumEntry := model.AlbumFileEntry{AlbumID: album.ID, FileID: file.ID, IsDeleted: file.IsDeleted, SyncedLocally: false} 95 | putErr := c.UpsertAlbumEntry(ctx, &albumEntry) 96 | if putErr != nil { 97 | return putErr 98 | } 99 | if file.IsDeleted { 100 | continue 101 | } 102 | photoFile, err := mapper.MapApiFileToPhotoFile(ctx, album, file, c.KeyHolder) 103 | if err != nil { 104 | return err 105 | } 106 | fileJson := encoding.MustMarshalJSON(photoFile) 107 | // todo: use batch put 108 | putErr = c.PutValue(ctx, model.RemoteFiles, []byte(strconv.FormatInt(file.ID, 10)), fileJson) 109 | if putErr != nil { 110 | return putErr 111 | } 112 | } 113 | if !hasMore { 114 | maxUpdated = album.LastUpdatedAt 115 | } 116 | if (maxUpdated > lastSyncTime) || !hasMore { 117 | log.Printf("Updating last sync time for album %s to %s\n", album.AlbumName, time.UnixMicro(maxUpdated)) 118 | err = c.PutConfigValue(ctx, fmt.Sprintf(model.CollectionsFileSyncKeyFmt, album.ID), []byte(strconv.FormatInt(maxUpdated, 10))) 119 | if err != nil { 120 | return fmt.Errorf("failed to update last sync time: %s", err) 121 | } else { 122 | lastSyncTime = maxUpdated 123 | } 124 | } 125 | } 126 | } 127 | return nil 128 | } 129 | 130 | func (c *ClICtrl) getRemoteAlbums(ctx context.Context) ([]model.RemoteAlbum, error) { 131 | albums := make([]model.RemoteAlbum, 0) 132 | albumBytes, err := c.GetAllValues(ctx, model.RemoteAlbums) 133 | if err != nil { 134 | return nil, err 135 | } 136 | for _, albumJson := range albumBytes { 137 | album := model.RemoteAlbum{} 138 | err = json.Unmarshal(albumJson, &album) 139 | if err != nil { 140 | return nil, err 141 | } 142 | albums = append(albums, album) 143 | } 144 | return albums, nil 145 | } 146 | 147 | func (c *ClICtrl) getRemoteFiles(ctx context.Context) ([]model.RemoteFile, error) { 148 | files := make([]model.RemoteFile, 0) 149 | fileBytes, err := c.GetAllValues(ctx, model.RemoteFiles) 150 | if err != nil { 151 | return nil, err 152 | } 153 | for _, fileJson := range fileBytes { 154 | file := model.RemoteFile{} 155 | err = json.Unmarshal(fileJson, &file) 156 | if err != nil { 157 | return nil, err 158 | } 159 | files = append(files, file) 160 | } 161 | return files, nil 162 | } 163 | 164 | func (c *ClICtrl) getRemoteAlbumEntries(ctx context.Context) ([]*model.AlbumFileEntry, error) { 165 | entries := make([]*model.AlbumFileEntry, 0) 166 | entryBytes, err := c.GetAllValues(ctx, model.RemoteAlbumEntries) 167 | if err != nil { 168 | return nil, err 169 | } 170 | for _, entryJson := range entryBytes { 171 | entry := &model.AlbumFileEntry{} 172 | err = json.Unmarshal(entryJson, &entry) 173 | if err != nil { 174 | return nil, err 175 | } 176 | entries = append(entries, entry) 177 | } 178 | return entries, nil 179 | } 180 | -------------------------------------------------------------------------------- /pkg/remote_to_disk_album.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/ente-io/cli/pkg/model" 8 | "github.com/ente-io/cli/pkg/model/export" 9 | "log" 10 | "os" 11 | "strings" 12 | 13 | "path/filepath" 14 | ) 15 | 16 | func (c *ClICtrl) createLocalFolderForRemoteAlbums(ctx context.Context, account model.Account) error { 17 | path := account.ExportDir 18 | albums, err := c.getRemoteAlbums(ctx) 19 | if err != nil { 20 | return err 21 | } 22 | userID := ctx.Value("user_id").(int64) 23 | folderToMetaMap, albumIDToMetaMap, err := readFolderMetadata(path) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | for _, album := range albums { 29 | if album.IsDeleted { 30 | if meta, ok := albumIDToMetaMap[album.ID]; ok { 31 | log.Printf("Deleting album %s as it is deleted", meta.AlbumName) 32 | if err = os.RemoveAll(filepath.Join(path, meta.FolderName)); err != nil { 33 | return err 34 | } 35 | delete(folderToMetaMap, meta.FolderName) 36 | delete(albumIDToMetaMap, meta.ID) 37 | } 38 | continue 39 | } 40 | metaByID := albumIDToMetaMap[album.ID] 41 | 42 | if metaByID != nil { 43 | if strings.EqualFold(metaByID.AlbumName, album.AlbumName) { 44 | //log.Printf("Skipping album %s as it already exists", album.AlbumName) 45 | continue 46 | } 47 | } 48 | 49 | albumFolderName := filepath.Clean(album.AlbumName) 50 | // replace : with _ 51 | albumFolderName = strings.ReplaceAll(albumFolderName, ":", "_") 52 | albumFolderName = strings.ReplaceAll(albumFolderName, "/", "_") 53 | albumFolderName = strings.TrimSpace(albumFolderName) 54 | 55 | albumID := album.ID 56 | 57 | if _, ok := folderToMetaMap[albumFolderName]; ok { 58 | for i := 1; ; i++ { 59 | newAlbumName := fmt.Sprintf("%s_%d", albumFolderName, i) 60 | if _, ok := folderToMetaMap[newAlbumName]; !ok { 61 | albumFolderName = newAlbumName 62 | break 63 | } 64 | } 65 | } 66 | // Create album and meta folders if they don't exist 67 | albumPath := filepath.Clean(filepath.Join(path, albumFolderName)) 68 | metaPath := filepath.Join(albumPath, ".meta") 69 | if metaByID == nil { 70 | log.Printf("Adding folder %s for album %s", albumFolderName, album.AlbumName) 71 | for _, p := range []string{albumPath, metaPath} { 72 | if _, err := os.Stat(p); os.IsNotExist(err) { 73 | if err = os.Mkdir(p, 0755); err != nil { 74 | return err 75 | } 76 | } 77 | } 78 | } else { 79 | // rename meta.FolderName to albumFolderName 80 | oldAlbumPath := filepath.Join(path, metaByID.FolderName) 81 | log.Printf("Renaming path from %s to %s for album %s", oldAlbumPath, albumPath, album.AlbumName) 82 | if err = os.Rename(oldAlbumPath, albumPath); err != nil { 83 | return err 84 | } 85 | } 86 | // Handle meta file 87 | metaFilePath := filepath.Join(path, albumFolderName, albumMetaFolder, albumMetaFile) 88 | metaData := export.AlbumMetadata{ 89 | ID: album.ID, 90 | OwnerID: album.OwnerID, 91 | AlbumName: album.AlbumName, 92 | IsDeleted: album.IsDeleted, 93 | AccountOwnerIDs: []int64{userID}, 94 | FolderName: albumFolderName, 95 | } 96 | if err = writeJSONToFile(metaFilePath, metaData); err != nil { 97 | return err 98 | } 99 | folderToMetaMap[albumFolderName] = &metaData 100 | albumIDToMetaMap[albumID] = &metaData 101 | } 102 | return nil 103 | } 104 | 105 | // readFolderMetadata returns a map of folder name to album metadata for all folders in the given path 106 | // and a map of album ID to album metadata for all albums in the given path. 107 | func readFolderMetadata(path string) (map[string]*export.AlbumMetadata, map[int64]*export.AlbumMetadata, error) { 108 | result := make(map[string]*export.AlbumMetadata) 109 | albumIdToMetadataMap := make(map[int64]*export.AlbumMetadata) 110 | // Read the top-level directories in the given path 111 | entries, err := os.ReadDir(path) 112 | if err != nil { 113 | return nil, nil, err 114 | } 115 | for _, entry := range entries { 116 | if entry.IsDir() { 117 | dirName := entry.Name() 118 | metaFilePath := filepath.Join(path, dirName, albumMetaFolder, albumMetaFile) 119 | // Initialize as nil, will remain nil if JSON file is not found or not readable 120 | result[dirName] = nil 121 | // Read the JSON file if it exists 122 | if _, err := os.Stat(metaFilePath); err == nil { 123 | var metaData export.AlbumMetadata 124 | metaDataBytes, err := os.ReadFile(metaFilePath) 125 | if err != nil { 126 | continue // Skip this entry if reading fails 127 | } 128 | 129 | if err := json.Unmarshal(metaDataBytes, &metaData); err == nil { 130 | metaData.FolderName = dirName 131 | result[dirName] = &metaData 132 | albumIdToMetadataMap[metaData.ID] = &metaData 133 | } 134 | } 135 | } 136 | } 137 | return result, albumIdToMetadataMap, nil 138 | } 139 | -------------------------------------------------------------------------------- /pkg/remote_to_disk_file.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "archive/zip" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "github.com/ente-io/cli/pkg/mapper" 10 | "github.com/ente-io/cli/pkg/model" 11 | "github.com/ente-io/cli/pkg/model/export" 12 | "github.com/ente-io/cli/utils" 13 | "log" 14 | "os" 15 | "path/filepath" 16 | "strings" 17 | "time" 18 | ) 19 | 20 | func (c *ClICtrl) syncFiles(ctx context.Context, account model.Account) error { 21 | log.Printf("Starting file download") 22 | exportRoot := account.ExportDir 23 | _, albumIDToMetaMap, err := readFolderMetadata(exportRoot) 24 | if err != nil { 25 | return err 26 | } 27 | entries, err := c.getRemoteAlbumEntries(ctx) 28 | if err != nil { 29 | return err 30 | } 31 | log.Println("total entries", len(entries)) 32 | model.SortAlbumFileEntry(entries) 33 | defer utils.TimeTrack(time.Now(), "process_files") 34 | var albumDiskInfo *albumDiskInfo 35 | for i, albumFileEntry := range entries { 36 | if albumFileEntry.SyncedLocally { 37 | continue 38 | } 39 | albumInfo, ok := albumIDToMetaMap[albumFileEntry.AlbumID] 40 | if !ok { 41 | log.Printf("Album %d not found in local metadata", albumFileEntry.AlbumID) 42 | continue 43 | } 44 | if albumInfo.IsDeleted { 45 | putErr := c.DeleteAlbumEntry(ctx, albumFileEntry) 46 | if putErr != nil { 47 | return putErr 48 | } 49 | continue 50 | } 51 | 52 | if albumDiskInfo == nil || albumDiskInfo.AlbumMeta.ID != albumInfo.ID { 53 | albumDiskInfo, err = readFilesMetadata(exportRoot, albumInfo) 54 | if err != nil { 55 | return err 56 | } 57 | } 58 | fileBytes, err := c.GetValue(ctx, model.RemoteFiles, []byte(fmt.Sprintf("%d", albumFileEntry.FileID))) 59 | if err != nil { 60 | return err 61 | } 62 | if fileBytes != nil { 63 | var existingEntry *model.RemoteFile 64 | err = json.Unmarshal(fileBytes, &existingEntry) 65 | if err != nil { 66 | return err 67 | } 68 | log.Printf("[%d/%d] Sync %s for album %s", i, len(entries), existingEntry.GetTitle(), albumInfo.AlbumName) 69 | err = c.downloadEntry(ctx, albumDiskInfo, *existingEntry, albumFileEntry) 70 | if err != nil { 71 | if errors.Is(err, model.ErrDecryption) { 72 | continue 73 | } else if existingEntry.IsLivePhoto() && errors.Is(err, zip.ErrFormat) { 74 | log.Printf(fmt.Sprintf("err processing live photo %s (%d), %s", existingEntry.GetTitle(), existingEntry.ID, err.Error())) 75 | continue 76 | } else if existingEntry.IsLivePhoto() && errors.Is(err, model.ErrLiveZip) { 77 | continue 78 | } else { 79 | return err 80 | } 81 | } 82 | } else { 83 | // file metadata is missing in the localDB 84 | if albumFileEntry.IsDeleted { 85 | delErr := c.DeleteAlbumEntry(ctx, albumFileEntry) 86 | if delErr != nil { 87 | log.Fatalf("Error deleting album entry %d (deleted: %v) %v", albumFileEntry.FileID, albumFileEntry.IsDeleted, delErr) 88 | } 89 | } else { 90 | log.Fatalf("Failed to find entry in db for file %d (deleted: %v)", albumFileEntry.FileID, albumFileEntry.IsDeleted) 91 | } 92 | } 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func (c *ClICtrl) downloadEntry(ctx context.Context, 99 | diskInfo *albumDiskInfo, 100 | file model.RemoteFile, 101 | albumEntry *model.AlbumFileEntry, 102 | ) error { 103 | if !diskInfo.AlbumMeta.IsDeleted && albumEntry.IsDeleted { 104 | albumEntry.IsDeleted = true 105 | diskFileMeta := diskInfo.GetDiskFileMetadata(file) 106 | if diskFileMeta != nil { 107 | removeErr := removeDiskFile(diskFileMeta, diskInfo) 108 | if removeErr != nil { 109 | return removeErr 110 | } 111 | } 112 | delErr := c.DeleteAlbumEntry(ctx, albumEntry) 113 | if delErr != nil { 114 | return delErr 115 | } 116 | return nil 117 | } 118 | diskFileMeta := diskInfo.GetDiskFileMetadata(file) 119 | if diskFileMeta != nil { 120 | removeErr := removeDiskFile(diskFileMeta, diskInfo) 121 | if removeErr != nil { 122 | return removeErr 123 | } 124 | } 125 | if !diskInfo.IsFilePresent(file) { 126 | decrypt, err := c.downloadAndDecrypt(ctx, file, c.KeyHolder.DeviceKey) 127 | if err != nil { 128 | return err 129 | } 130 | fileDiskMetadata := mapper.MapRemoteFileToDiskMetadata(file) 131 | // Get the extension 132 | extension := filepath.Ext(fileDiskMetadata.Title) 133 | baseFileName := strings.TrimSuffix(filepath.Clean(filepath.Base(fileDiskMetadata.Title)), extension) 134 | diskMetaFileName := diskInfo.GenerateUniqueMetaFileName(baseFileName, extension) 135 | if file.IsLivePhoto() { 136 | imagePath, videoPath, err := UnpackLive(*decrypt) 137 | if err != nil { 138 | return err 139 | } 140 | if imagePath == "" && videoPath == "" { 141 | log.Printf("imagePath %s, videoPath %s", imagePath, videoPath) 142 | return model.ErrLiveZip 143 | } 144 | if imagePath != "" { 145 | imageExtn := filepath.Ext(imagePath) 146 | imageFileName := diskInfo.GenerateUniqueFileName(baseFileName, imageExtn) 147 | imageFilePath := filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, imageFileName) 148 | moveErr := Move(imagePath, imageFilePath) 149 | if moveErr != nil { 150 | return moveErr 151 | } 152 | fileDiskMetadata.AddFileName(imageFileName) 153 | } 154 | if videoPath == "" { 155 | videoExtn := filepath.Ext(videoPath) 156 | videoFileName := diskInfo.GenerateUniqueFileName(baseFileName, videoExtn) 157 | videoFilePath := filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, videoFileName) 158 | // move the decrypt file to filePath 159 | moveErr := Move(videoPath, videoFilePath) 160 | if moveErr != nil { 161 | return moveErr 162 | } 163 | fileDiskMetadata.AddFileName(videoFileName) 164 | } 165 | } else { 166 | fileName := diskInfo.GenerateUniqueFileName(baseFileName, extension) 167 | filePath := filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, fileName) 168 | // move the decrypt file to filePath 169 | err = Move(*decrypt, filePath) 170 | if err != nil { 171 | return err 172 | } 173 | fileDiskMetadata.AddFileName(fileName) 174 | } 175 | 176 | fileDiskMetadata.MetaFileName = diskMetaFileName 177 | err = diskInfo.AddEntry(fileDiskMetadata) 178 | if err != nil { 179 | return err 180 | } 181 | 182 | err = writeJSONToFile(filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, ".meta", diskMetaFileName), fileDiskMetadata) 183 | if err != nil { 184 | return err 185 | } 186 | albumEntry.SyncedLocally = true 187 | putErr := c.UpsertAlbumEntry(ctx, albumEntry) 188 | if putErr != nil { 189 | return putErr 190 | } 191 | } 192 | return nil 193 | } 194 | 195 | func removeDiskFile(diskFileMeta *export.DiskFileMetadata, diskInfo *albumDiskInfo) error { 196 | // remove the file from disk 197 | log.Printf("Removing file %s from disk", diskFileMeta.MetaFileName) 198 | err := os.Remove(filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, ".meta", diskFileMeta.MetaFileName)) 199 | if err != nil && !os.IsNotExist(err) { 200 | return err 201 | } 202 | for _, fileName := range diskFileMeta.Info.FileNames { 203 | err = os.Remove(filepath.Join(diskInfo.ExportRoot, diskInfo.AlbumMeta.FolderName, fileName)) 204 | if err != nil && !os.IsNotExist(err) { 205 | return err 206 | } 207 | } 208 | return diskInfo.RemoveEntry(diskFileMeta) 209 | } 210 | 211 | // readFolderMetadata reads the metadata of the files in the given path 212 | // For disk export, a particular albums files are stored in a folder named after the album. 213 | // Inside the folder, the files are stored at top level and its metadata is stored in a .meta folder 214 | func readFilesMetadata(home string, albumMeta *export.AlbumMetadata) (*albumDiskInfo, error) { 215 | albumMetadataFolder := filepath.Join(home, albumMeta.FolderName, albumMetaFolder) 216 | albumPath := filepath.Join(home, albumMeta.FolderName) 217 | // verify the both the album folder and the .meta folder exist 218 | if _, err := os.Stat(albumMetadataFolder); err != nil { 219 | return nil, err 220 | } 221 | if _, err := os.Stat(albumPath); err != nil { 222 | return nil, err 223 | } 224 | result := make(map[string]*export.DiskFileMetadata) 225 | //fileNameToFileName := make(map[string]*export.DiskFileMetadata) 226 | fileIdToMetadata := make(map[int64]*export.DiskFileMetadata) 227 | claimedFileName := make(map[string]bool) 228 | // Read the top-level directories in the given path 229 | albumFileEntries, err := os.ReadDir(albumPath) 230 | if err != nil { 231 | return nil, err 232 | } 233 | for _, entry := range albumFileEntries { 234 | if !entry.IsDir() { 235 | claimedFileName[strings.ToLower(entry.Name())] = true 236 | } 237 | } 238 | metaEntries, err := os.ReadDir(albumMetadataFolder) 239 | if err != nil { 240 | return nil, err 241 | } 242 | for _, entry := range metaEntries { 243 | if !entry.IsDir() { 244 | fileName := entry.Name() 245 | if fileName == albumMetaFile { 246 | continue 247 | } 248 | if !strings.HasSuffix(fileName, ".json") { 249 | log.Printf("Skipping file %s as it is not a JSON file", fileName) 250 | continue 251 | } 252 | fileMetadataPath := filepath.Join(albumMetadataFolder, fileName) 253 | // Initialize as nil, will remain nil if JSON file is not found or not readable 254 | result[strings.ToLower(fileName)] = nil 255 | // Read the JSON file if it exists 256 | var metaData export.DiskFileMetadata 257 | metaDataBytes, err := os.ReadFile(fileMetadataPath) 258 | if err != nil { 259 | continue // Skip this entry if reading fails 260 | } 261 | if err := json.Unmarshal(metaDataBytes, &metaData); err == nil { 262 | metaData.MetaFileName = fileName 263 | result[strings.ToLower(fileName)] = &metaData 264 | fileIdToMetadata[metaData.Info.ID] = &metaData 265 | } 266 | } 267 | } 268 | return &albumDiskInfo{ 269 | ExportRoot: home, 270 | AlbumMeta: albumMeta, 271 | FileNames: &claimedFileName, 272 | MetaFileNameToDiskFileMap: &result, 273 | FileIdToDiskFileMap: &fileIdToMetadata, 274 | }, nil 275 | } 276 | -------------------------------------------------------------------------------- /pkg/secrets/key_holder.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/ente-io/cli/internal/api" 7 | eCrypto "github.com/ente-io/cli/internal/crypto" 8 | "github.com/ente-io/cli/pkg/model" 9 | "github.com/ente-io/cli/utils/encoding" 10 | ) 11 | 12 | type KeyHolder struct { 13 | // DeviceKey is the key used to encrypt/decrypt the data while storing sensitive 14 | // information on the disk. Usually, it should be stored in OS Keychain. 15 | DeviceKey []byte 16 | AccountSecrets map[string]*model.AccSecretInfo 17 | CollectionKeys map[string][]byte 18 | } 19 | 20 | func NewKeyHolder(deviceKey []byte) *KeyHolder { 21 | return &KeyHolder{ 22 | AccountSecrets: make(map[string]*model.AccSecretInfo), 23 | CollectionKeys: make(map[string][]byte), 24 | DeviceKey: deviceKey, 25 | } 26 | } 27 | 28 | // LoadSecrets loads the secrets for a given account using the provided CLI key. 29 | // It decrypts the token key, master key, and secret key using the CLI key. 30 | // The decrypted keys and the decoded public key are stored in the AccountSecrets map using the account key as the map key. 31 | // It returns the account secret information or an error if the decryption fails. 32 | func (k *KeyHolder) LoadSecrets(account model.Account) (*model.AccSecretInfo, error) { 33 | tokenKey := account.Token.MustDecrypt(k.DeviceKey) 34 | masterKey := account.MasterKey.MustDecrypt(k.DeviceKey) 35 | secretKey := account.SecretKey.MustDecrypt(k.DeviceKey) 36 | k.AccountSecrets[account.AccountKey()] = &model.AccSecretInfo{ 37 | Token: tokenKey, 38 | MasterKey: masterKey, 39 | SecretKey: secretKey, 40 | PublicKey: encoding.DecodeBase64(account.PublicKey), 41 | } 42 | return k.AccountSecrets[account.AccountKey()], nil 43 | } 44 | 45 | func (k *KeyHolder) GetAccountSecretInfo(ctx context.Context) *model.AccSecretInfo { 46 | accountKey := ctx.Value("account_key").(string) 47 | return k.AccountSecrets[accountKey] 48 | } 49 | 50 | // GetCollectionKey retrieves the key for a given collection. 51 | // It first fetches the account secret information from the context. 52 | // If the collection owner's ID matches the user ID from the context, it decrypts the collection key using the master key. 53 | // 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. 54 | // It returns the decrypted collection key or an error if the decryption fails. 55 | func (k *KeyHolder) GetCollectionKey(ctx context.Context, collection api.Collection) ([]byte, error) { 56 | accSecretInfo := k.GetAccountSecretInfo(ctx) 57 | userID := ctx.Value("user_id").(int64) 58 | if collection.Owner.ID == userID { 59 | collKey, err := eCrypto.SecretBoxOpen( 60 | encoding.DecodeBase64(collection.EncryptedKey), 61 | encoding.DecodeBase64(collection.KeyDecryptionNonce), 62 | accSecretInfo.MasterKey) 63 | if err != nil { 64 | return nil, fmt.Errorf("collection %d key drive failed %s", collection.ID, err) 65 | } 66 | return collKey, nil 67 | } else { 68 | collKey, err := eCrypto.SealedBoxOpen(encoding.DecodeBase64(collection.EncryptedKey), 69 | accSecretInfo.PublicKey, accSecretInfo.SecretKey) 70 | if err != nil { 71 | return nil, fmt.Errorf("shared collection %d key drive failed %s", collection.ID, err) 72 | } 73 | return collKey, nil 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /pkg/secrets/secret.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "crypto/rand" 5 | "errors" 6 | "fmt" 7 | "github.com/ente-io/cli/utils/constants" 8 | "log" 9 | "os" 10 | 11 | "github.com/zalando/go-keyring" 12 | ) 13 | 14 | func IsRunningInContainer() bool { 15 | if _, err := os.Stat("/.dockerenv"); err != nil { 16 | return false 17 | } 18 | return true 19 | } 20 | 21 | const ( 22 | secretService = "ente" 23 | secretUser = "ente-cli-user" 24 | ) 25 | 26 | func GetOrCreateClISecret() []byte { 27 | // get password 28 | secret, err := keyring.Get(secretService, secretUser) 29 | if err != nil { 30 | if !errors.Is(err, keyring.ErrNotFound) { 31 | if IsRunningInContainer() { 32 | return GetSecretFromSecretText() 33 | } else { 34 | log.Fatal(fmt.Errorf("error getting password from keyring: %w", err)) 35 | } 36 | } 37 | key := make([]byte, 32) 38 | _, err = rand.Read(key) 39 | if err != nil { 40 | log.Fatal(fmt.Errorf("error generating key: %w", err)) 41 | } 42 | secret = string(key) 43 | keySetErr := keyring.Set(secretService, secretUser, string(secret)) 44 | if keySetErr != nil { 45 | log.Fatal(fmt.Errorf("error setting password in keyring: %w", keySetErr)) 46 | } 47 | 48 | } 49 | return []byte(secret) 50 | } 51 | 52 | // GetSecretFromSecretText reads the scecret from the secret text file. 53 | // If the file does not exist, it will be created and write random 32 byte secret to it. 54 | func GetSecretFromSecretText() []byte { 55 | // Define the path to the secret text file 56 | secretFilePath := fmt.Sprintf("%s.secret.txt", constants.CliDataPath) 57 | 58 | // Check if file exists 59 | _, err := os.Stat(secretFilePath) 60 | if err != nil { 61 | if !errors.Is(err, os.ErrNotExist) { 62 | log.Fatal(fmt.Errorf("error checking secret file: %w", err)) 63 | } 64 | // File does not exist; create and write a random 32-byte secret 65 | key := make([]byte, 32) 66 | _, err := rand.Read(key) 67 | if err != nil { 68 | log.Fatal(fmt.Errorf("error generating key: %w", err)) 69 | } 70 | err = os.WriteFile(secretFilePath, key, 0644) 71 | if err != nil { 72 | log.Fatal(fmt.Errorf("error writing to secret file: %w", err)) 73 | } 74 | return key 75 | } 76 | // File exists; read the secret 77 | secret, err := os.ReadFile(secretFilePath) 78 | if err != nil { 79 | log.Fatal(fmt.Errorf("error reading from secret file: %w", err)) 80 | } 81 | return secret 82 | } 83 | -------------------------------------------------------------------------------- /pkg/sign_in.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/ente-io/cli/internal" 7 | "github.com/ente-io/cli/internal/api" 8 | eCrypto "github.com/ente-io/cli/internal/crypto" 9 | "github.com/ente-io/cli/pkg/model" 10 | "github.com/ente-io/cli/utils/encoding" 11 | "log" 12 | 13 | "github.com/kong/go-srp" 14 | ) 15 | 16 | func (c *ClICtrl) signInViaPassword(ctx context.Context, srpAttr *api.SRPAttributes) (*api.AuthorizationResponse, []byte, error) { 17 | for { 18 | // CLI prompt for password 19 | password, flowErr := internal.GetSensitiveField("Enter password") 20 | if flowErr != nil { 21 | return nil, nil, flowErr 22 | } 23 | fmt.Println("\nPlease wait authenticating...") 24 | keyEncKey, err := eCrypto.DeriveArgonKey(password, srpAttr.KekSalt, srpAttr.MemLimit, srpAttr.OpsLimit) 25 | if err != nil { 26 | fmt.Printf("error deriving key encryption key: %v", err) 27 | return nil, nil, err 28 | } 29 | loginKey := eCrypto.DeriveLoginKey(keyEncKey) 30 | 31 | srpParams := srp.GetParams(4096) 32 | identify := []byte(srpAttr.SRPUserID.String()) 33 | salt := encoding.DecodeBase64(srpAttr.SRPSalt) 34 | clientSecret := srp.GenKey() 35 | srpClient := srp.NewClient(srpParams, salt, identify, loginKey, clientSecret) 36 | clientA := srpClient.ComputeA() 37 | session, err := c.Client.CreateSRPSession(ctx, srpAttr.SRPUserID, encoding.EncodeBase64(clientA)) 38 | if err != nil { 39 | return nil, nil, err 40 | } 41 | serverB := session.SRPB 42 | srpClient.SetB(encoding.DecodeBase64(serverB)) 43 | clientM := srpClient.ComputeM1() 44 | authResp, err := c.Client.VerifySRPSession(ctx, srpAttr.SRPUserID, session.SessionID, encoding.EncodeBase64(clientM)) 45 | if err != nil { 46 | log.Printf("failed to verify %v", err) 47 | continue 48 | } 49 | return authResp, keyEncKey, nil 50 | } 51 | } 52 | 53 | // Parameters: 54 | // - keyEncKey: key encryption key is derived from user's password. During SRP based login, this key is already derived. 55 | // So, we can pass it to avoid asking for password again. 56 | func (c *ClICtrl) decryptAccSecretInfo( 57 | _ context.Context, 58 | authResp *api.AuthorizationResponse, 59 | keyEncKey []byte, 60 | ) (*model.AccSecretInfo, error) { 61 | var currentKeyEncKey []byte 62 | var err error 63 | var masterKey, secretKey, tokenKey []byte 64 | var publicKey = encoding.DecodeBase64(authResp.KeyAttributes.PublicKey) 65 | for { 66 | if keyEncKey == nil { 67 | // CLI prompt for password 68 | password, flowErr := internal.GetSensitiveField("Enter password") 69 | if flowErr != nil { 70 | return nil, flowErr 71 | } 72 | fmt.Println("\nPlease wait authenticating...") 73 | currentKeyEncKey, err = eCrypto.DeriveArgonKey(password, 74 | authResp.KeyAttributes.KEKSalt, authResp.KeyAttributes.MemLimit, authResp.KeyAttributes.OpsLimit) 75 | if err != nil { 76 | fmt.Printf("error deriving key encryption key: %v", err) 77 | return nil, err 78 | } 79 | } else { 80 | currentKeyEncKey = keyEncKey 81 | } 82 | 83 | encryptedKey := encoding.DecodeBase64(authResp.KeyAttributes.EncryptedKey) 84 | encryptedKeyNonce := encoding.DecodeBase64(authResp.KeyAttributes.KeyDecryptionNonce) 85 | masterKey, err = eCrypto.SecretBoxOpen(encryptedKey, encryptedKeyNonce, currentKeyEncKey) 86 | if err != nil { 87 | if keyEncKey != nil { 88 | fmt.Printf("Failed to get key from keyEncryptionKey %s", err) 89 | return nil, err 90 | } else { 91 | fmt.Printf("Incorrect password, error decrypting master key: %v", err) 92 | continue 93 | } 94 | } 95 | secretKey, err = eCrypto.SecretBoxOpen( 96 | encoding.DecodeBase64(authResp.KeyAttributes.EncryptedSecretKey), 97 | encoding.DecodeBase64(authResp.KeyAttributes.SecretKeyDecryptionNonce), 98 | masterKey, 99 | ) 100 | if err != nil { 101 | fmt.Printf("error decrypting master key: %v", err) 102 | return nil, err 103 | } 104 | tokenKey, err = eCrypto.SealedBoxOpen( 105 | encoding.DecodeBase64(authResp.EncryptedToken), 106 | publicKey, 107 | secretKey, 108 | ) 109 | if err != nil { 110 | fmt.Printf("error decrypting token: %v", err) 111 | return nil, err 112 | } 113 | break 114 | } 115 | return &model.AccSecretInfo{ 116 | MasterKey: masterKey, 117 | SecretKey: secretKey, 118 | Token: tokenKey, 119 | PublicKey: publicKey, 120 | }, nil 121 | } 122 | 123 | func (c *ClICtrl) validateTOTP(ctx context.Context, authResp *api.AuthorizationResponse) (*api.AuthorizationResponse, error) { 124 | if !authResp.IsMFARequired() { 125 | return authResp, nil 126 | } 127 | for { 128 | // CLI prompt for TOTP 129 | totp, flowErr := internal.GetCode("Enter TOTP", 6) 130 | if flowErr != nil { 131 | return nil, flowErr 132 | } 133 | totpResp, err := c.Client.VerifyTotp(ctx, authResp.TwoFactorSessionID, totp) 134 | if err != nil { 135 | log.Printf("failed to verify %v", err) 136 | continue 137 | } 138 | return totpResp, nil 139 | } 140 | } 141 | 142 | func (c *ClICtrl) validateEmail(ctx context.Context, email string) (*api.AuthorizationResponse, error) { 143 | err := c.Client.SendEmailOTP(ctx, email) 144 | if err != nil { 145 | return nil, err 146 | } 147 | for { 148 | // CLI prompt for OTP 149 | ott, flowErr := internal.GetCode("Enter OTP", 6) 150 | if flowErr != nil { 151 | return nil, flowErr 152 | } 153 | authResponse, err := c.Client.VerifyEmail(ctx, email, ott) 154 | if err != nil { 155 | log.Printf("failed to verify %v", err) 156 | continue 157 | } 158 | return authResponse, nil 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /pkg/store.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/ente-io/cli/pkg/model" 7 | "log" 8 | "strconv" 9 | "time" 10 | 11 | bolt "go.etcd.io/bbolt" 12 | ) 13 | 14 | func GetDB(path string) (*bolt.DB, error) { 15 | db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second}) 16 | if err != nil { 17 | log.Fatal(err) 18 | } 19 | return db, err 20 | } 21 | 22 | func (c *ClICtrl) GetInt64ConfigValue(ctx context.Context, key string) (int64, error) { 23 | value, err := c.getConfigValue(ctx, key) 24 | if err != nil { 25 | return 0, err 26 | } 27 | var result int64 28 | if value != nil { 29 | result, err = strconv.ParseInt(string(value), 10, 64) 30 | if err != nil { 31 | return 0, err 32 | } 33 | } 34 | return result, nil 35 | } 36 | 37 | func (c *ClICtrl) getConfigValue(ctx context.Context, key string) ([]byte, error) { 38 | var value []byte 39 | err := c.DB.View(func(tx *bolt.Tx) error { 40 | kvBucket, err := getAccountStore(ctx, tx, model.KVConfig) 41 | if err != nil { 42 | return err 43 | } 44 | value = kvBucket.Get([]byte(key)) 45 | return nil 46 | }) 47 | return value, err 48 | } 49 | 50 | func (c *ClICtrl) GetAllValues(ctx context.Context, store model.PhotosStore) ([][]byte, error) { 51 | result := make([][]byte, 0) 52 | err := c.DB.View(func(tx *bolt.Tx) error { 53 | kvBucket, err := getAccountStore(ctx, tx, store) 54 | if err != nil { 55 | return err 56 | } 57 | kvBucket.ForEach(func(k, v []byte) error { 58 | result = append(result, v) 59 | return nil 60 | }) 61 | return nil 62 | }) 63 | return result, err 64 | } 65 | 66 | func (c *ClICtrl) PutConfigValue(ctx context.Context, key string, value []byte) error { 67 | return c.DB.Update(func(tx *bolt.Tx) error { 68 | kvBucket, err := getAccountStore(ctx, tx, model.KVConfig) 69 | if err != nil { 70 | return err 71 | } 72 | return kvBucket.Put([]byte(key), value) 73 | }) 74 | } 75 | func (c *ClICtrl) PutValue(ctx context.Context, store model.PhotosStore, key []byte, value []byte) error { 76 | return c.DB.Update(func(tx *bolt.Tx) error { 77 | kvBucket, err := getAccountStore(ctx, tx, store) 78 | if err != nil { 79 | return err 80 | } 81 | return kvBucket.Put(key, value) 82 | }) 83 | } 84 | 85 | func (c *ClICtrl) DeleteValue(ctx context.Context, store model.PhotosStore, key []byte) error { 86 | return c.DB.Update(func(tx *bolt.Tx) error { 87 | kvBucket, err := getAccountStore(ctx, tx, store) 88 | if err != nil { 89 | return err 90 | } 91 | return kvBucket.Delete(key) 92 | }) 93 | } 94 | 95 | // GetValue 96 | func (c *ClICtrl) GetValue(ctx context.Context, store model.PhotosStore, key []byte) ([]byte, error) { 97 | var value []byte 98 | err := c.DB.View(func(tx *bolt.Tx) error { 99 | kvBucket, err := getAccountStore(ctx, tx, store) 100 | if err != nil { 101 | return err 102 | } 103 | value = kvBucket.Get(key) 104 | return nil 105 | }) 106 | return value, err 107 | } 108 | func getAccountStore(ctx context.Context, tx *bolt.Tx, storeType model.PhotosStore) (*bolt.Bucket, error) { 109 | accountKey := ctx.Value("account_key").(string) 110 | accountBucket := tx.Bucket([]byte(accountKey)) 111 | if accountBucket == nil { 112 | return nil, fmt.Errorf("account bucket not found") 113 | } 114 | store := accountBucket.Bucket([]byte(storeType)) 115 | if store == nil { 116 | return nil, fmt.Errorf("store %s not found", storeType) 117 | } 118 | return store, nil 119 | } 120 | -------------------------------------------------------------------------------- /pkg/sync.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "github.com/ente-io/cli/internal" 8 | "github.com/ente-io/cli/internal/api" 9 | "github.com/ente-io/cli/pkg/model" 10 | bolt "go.etcd.io/bbolt" 11 | "log" 12 | "time" 13 | ) 14 | 15 | func (c *ClICtrl) Export() error { 16 | accounts, err := c.GetAccounts(context.Background()) 17 | if err != nil { 18 | return err 19 | } 20 | if len(accounts) == 0 { 21 | fmt.Printf("No accounts to sync\n Add account using `account add` cmd\n") 22 | return nil 23 | } 24 | for _, account := range accounts { 25 | log.SetPrefix(fmt.Sprintf("[%s-%s] ", account.App, account.Email)) 26 | if account.ExportDir == "" { 27 | log.Printf("Skip account %s: no export directory configured", account.Email) 28 | continue 29 | } 30 | _, err = internal.ValidateDirForWrite(account.ExportDir) 31 | if err != nil { 32 | log.Printf("Skip export, error: %v while validing exportDir %s\n", err, account.ExportDir) 33 | continue 34 | } 35 | if account.App == api.AppAuth { 36 | log.Printf("Skip account %s: auth export is not supported", account.Email) 37 | continue 38 | } 39 | log.Println("start sync") 40 | retryCount := 0 41 | for { 42 | err = c.SyncAccount(account) 43 | if err != nil { 44 | if model.ShouldRetrySync(err) && retryCount < 20 { 45 | retryCount = retryCount + 1 46 | timeInSecond := time.Duration(retryCount*10) * time.Second 47 | log.Printf("Connection err, waiting for %s before trying again", timeInSecond.String()) 48 | time.Sleep(timeInSecond) 49 | continue 50 | } 51 | fmt.Printf("Error syncing account %s: %s\n", account.Email, err) 52 | return err 53 | } else { 54 | log.Println("sync done") 55 | break 56 | } 57 | } 58 | 59 | } 60 | return nil 61 | } 62 | 63 | func (c *ClICtrl) SyncAccount(account model.Account) error { 64 | secretInfo, err := c.KeyHolder.LoadSecrets(account) 65 | if err != nil { 66 | return err 67 | } 68 | ctx := c.buildRequestContext(context.Background(), account) 69 | err = createDataBuckets(c.DB, account) 70 | if err != nil { 71 | return err 72 | } 73 | c.Client.AddToken(account.AccountKey(), base64.URLEncoding.EncodeToString(secretInfo.Token)) 74 | err = c.fetchRemoteCollections(ctx) 75 | if err != nil { 76 | log.Printf("Error fetching collections: %s", err) 77 | return err 78 | } 79 | err = c.fetchRemoteFiles(ctx) 80 | if err != nil { 81 | log.Printf("Error fetching files: %s", err) 82 | return err 83 | } 84 | err = c.createLocalFolderForRemoteAlbums(ctx, account) 85 | if err != nil { 86 | log.Printf("Error creating local folders: %s", err) 87 | return err 88 | } 89 | err = c.syncFiles(ctx, account) 90 | if err != nil { 91 | log.Printf("Error syncing files: %s", err) 92 | return err 93 | } 94 | return nil 95 | } 96 | 97 | func (c *ClICtrl) buildRequestContext(ctx context.Context, account model.Account) context.Context { 98 | ctx = context.WithValue(ctx, "app", string(account.App)) 99 | ctx = context.WithValue(ctx, "account_key", account.AccountKey()) 100 | ctx = context.WithValue(ctx, "user_id", account.UserID) 101 | return ctx 102 | } 103 | 104 | func createDataBuckets(db *bolt.DB, account model.Account) error { 105 | return db.Update(func(tx *bolt.Tx) error { 106 | dataBucket, err := tx.CreateBucketIfNotExists([]byte(account.AccountKey())) 107 | if err != nil { 108 | return fmt.Errorf("create bucket: %s", err) 109 | } 110 | for _, subBucket := range []model.PhotosStore{model.KVConfig, model.RemoteAlbums, model.RemoteFiles, model.RemoteAlbumEntries} { 111 | _, err := dataBucket.CreateBucketIfNotExists([]byte(subBucket)) 112 | if err != nil { 113 | return err 114 | } 115 | } 116 | return nil 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Create a "bin" directory if it doesn't exist 4 | mkdir -p bin 5 | 6 | # List of target operating systems 7 | OS_TARGETS=("windows" "linux" "darwin") 8 | 9 | # Corresponding architectures for each OS 10 | ARCH_TARGETS=("386 amd64" "386 amd64 arm arm64" "amd64 arm64") 11 | 12 | # Loop through each OS target 13 | for index in "${!OS_TARGETS[@]}" 14 | do 15 | OS=${OS_TARGETS[$index]} 16 | for ARCH in ${ARCH_TARGETS[$index]} 17 | do 18 | # Set the GOOS environment variable for the current target OS 19 | export GOOS="$OS" 20 | export GOARCH="$ARCH" 21 | 22 | # Set the output binary name to "ente-cli" for the current OS and architecture 23 | BINARY_NAME="ente-$OS-$ARCH" 24 | 25 | # Add .exe extension for Windows 26 | if [ "$OS" == "windows" ]; then 27 | BINARY_NAME="ente-$OS-$ARCH.exe" 28 | fi 29 | 30 | # Build the binary and place it in the "bin" directory 31 | go build -o "bin/$BINARY_NAME" main.go 32 | 33 | # Print a message indicating the build is complete for the current OS and architecture 34 | echo "Built for $OS ($ARCH) as bin/$BINARY_NAME" 35 | done 36 | done 37 | 38 | # Clean up any environment variables 39 | unset GOOS 40 | unset GOARCH 41 | 42 | # Print a message indicating the build process is complete 43 | echo "Build process completed for all platforms and architectures. Binaries are in the 'bin' directory." 44 | -------------------------------------------------------------------------------- /utils/constants/constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const CliDataPath = "/cli-data/" 4 | -------------------------------------------------------------------------------- /utils/encoding/encoding.go: -------------------------------------------------------------------------------- 1 | package encoding 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | ) 7 | 8 | func DecodeBase64(s string) []byte { 9 | b, err := base64.StdEncoding.DecodeString(s) 10 | if err != nil { 11 | panic(err) 12 | } 13 | return b 14 | } 15 | 16 | func EncodeBase64(b []byte) string { 17 | return base64.StdEncoding.EncodeToString(b) 18 | } 19 | 20 | func MustMarshalJSON(v interface{}) []byte { 21 | b, err := json.Marshal(v) 22 | if err != nil { 23 | panic(err) 24 | } 25 | return b 26 | } 27 | -------------------------------------------------------------------------------- /utils/time.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | ) 8 | 9 | func TimeTrack(start time.Time, name string) { 10 | elapsed := time.Since(start) 11 | log.Printf("%s took %s", name, elapsed) 12 | } 13 | 14 | func ByteCountDecimal(b int64) string { 15 | const unit = 1000 16 | if b < unit { 17 | return fmt.Sprintf("%d B", b) 18 | } 19 | div, exp := int64(unit), 0 20 | for n := b / unit; n >= unit; n /= unit { 21 | div *= unit 22 | exp++ 23 | } 24 | return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp]) 25 | } 26 | --------------------------------------------------------------------------------