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