├── .gitignore ├── files ├── sql │ ├── migration_20210402.sql │ ├── migration_20190714.sql │ ├── migration_20190904.sql │ └── db.sql └── template │ ├── text │ └── request_password_recovery.txt │ └── html │ └── request_password_recovery.html ├── domain ├── string_generator.go ├── hasher.go ├── mailer.go ├── user_storage.go ├── storage_provider.go ├── link.go ├── user.go ├── user │ ├── storage_provider.go │ ├── user.go │ └── user_test.go └── link │ ├── link.go │ └── link_test.go ├── gqlgen.yml ├── infrastructure ├── stringgenerator │ ├── uuid.go │ └── mock.go ├── hasher │ ├── plain.go │ └── bcrypt.go ├── auth │ ├── jwt_mock.go │ └── jwt.go ├── database │ ├── mysql │ │ ├── mysql.go │ │ ├── user.go │ │ ├── link.go │ │ └── user_storage.go │ └── inmemory │ │ ├── user.go │ │ ├── link.go │ │ ├── mem.go │ │ └── user_storage.go ├── mailer │ ├── mailtrap.go │ ├── mock.go │ └── sendgrid.go └── storageprovider │ ├── mock.go │ └── dropbox.go ├── README.MD ├── config.sample.yaml ├── Dockerfile ├── CONTRIBUTING.MD ├── models_gen.go ├── .gitlab-ci.yml ├── schema.graphql ├── go.mod ├── server ├── fileupload.go └── server.go ├── resolver.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | config.yaml 2 | cover.out -------------------------------------------------------------------------------- /files/sql/migration_20210402.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE bcc_drophere.users ADD password varchar(80) NULL; -------------------------------------------------------------------------------- /domain/string_generator.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | // StringGenerator abstraction 4 | type StringGenerator interface { 5 | Generate() string 6 | } -------------------------------------------------------------------------------- /files/sql/migration_20190714.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `users` 2 | ADD `recover_password_token` varchar(255) NULL, 3 | ADD `recover_password_token_expiry` datetime NULL; -------------------------------------------------------------------------------- /domain/hasher.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | // Hasher abstraction 4 | type Hasher interface { 5 | Hash(s string) (string, error) 6 | Verify(hashed, plain string) bool 7 | } -------------------------------------------------------------------------------- /files/template/text/request_password_recovery.txt: -------------------------------------------------------------------------------- 1 | {{define "request_password_recovery_text"}} 2 | Password Recovery Token: {{.Token}} 3 | {{.ResetPasswordLink}} 4 | {{end}} -------------------------------------------------------------------------------- /gqlgen.yml: -------------------------------------------------------------------------------- 1 | # .gqlgen.yml example 2 | # 3 | # Refer to https://gqlgen.com/config/ 4 | # for detailed .gqlgen.yml documentation. 5 | 6 | schema: 7 | - schema.graphql 8 | exec: 9 | filename: generated.go 10 | model: 11 | filename: models_gen.go 12 | resolver: 13 | filename: resolver.go 14 | type: Resolver 15 | -------------------------------------------------------------------------------- /domain/mailer.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "errors" 4 | 5 | // ErrTemplateNotFound error 6 | var ErrTemplateNotFound = errors.New("Template not found") 7 | 8 | // MailAddress model 9 | type MailAddress struct { 10 | Address string 11 | Name string 12 | } 13 | 14 | // Mailer abstraction 15 | type Mailer interface { 16 | Send(from, to MailAddress, subject, messagePlain, messageHTML string) error 17 | } 18 | -------------------------------------------------------------------------------- /infrastructure/stringgenerator/uuid.go: -------------------------------------------------------------------------------- 1 | package stringgenerator 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/bccfilkom/drophere-go/domain" 7 | "github.com/gofrs/uuid" 8 | ) 9 | 10 | type myUUID struct{} 11 | 12 | // NewUUID returns uuid token generator 13 | func NewUUID() domain.StringGenerator { 14 | return &myUUID{} 15 | } 16 | 17 | // Generate generates random string 18 | func (u *myUUID) Generate() string { 19 | token := uuid.Must(uuid.NewV4()) 20 | return strings.ReplaceAll(token.String(), "-", "") 21 | } 22 | -------------------------------------------------------------------------------- /infrastructure/hasher/plain.go: -------------------------------------------------------------------------------- 1 | package hasher 2 | 3 | import "github.com/bccfilkom/drophere-go/domain" 4 | 5 | type notAHasher struct{} 6 | 7 | // NewNotAHasher returns notAHasher instance for testing purpose 8 | func NewNotAHasher() domain.Hasher { 9 | return ¬AHasher{} 10 | } 11 | 12 | // Hash implementation 13 | func (n *notAHasher) Hash(s string) (string, error) { 14 | return s, nil 15 | } 16 | 17 | // Verify implementation 18 | func (n *notAHasher) Verify(hashed, plain string) bool { 19 | return hashed == plain 20 | } 21 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Drophere 2 | Drophere is a platform for submitting your task quickly. 3 | 4 | ## How to run locally 5 | 1. Copy the config file from ``config.sample.yaml`` to ``config.yaml``. 6 | 2. Execute db.sql from this [file][migration-file]. 7 | 3. Run ``go run server/*.go`` to start the app. 8 | 4. Browse to ``localhost:8080`` by your own browser. 9 | 10 | ## Contributing to this project 11 | Interested in contributing? please check out [the Contributing Guide](CONTRIBUTING.MD) to get started 12 | 13 | [migration-file]: files/sql/db.sql 14 | -------------------------------------------------------------------------------- /infrastructure/stringgenerator/mock.go: -------------------------------------------------------------------------------- 1 | package stringgenerator 2 | 3 | import "github.com/bccfilkom/drophere-go/domain" 4 | 5 | var preset string 6 | 7 | type mockStringGenerator struct{} 8 | 9 | // SetMockResult set the string that Generate returns 10 | func SetMockResult(s string) { 11 | preset = s 12 | } 13 | 14 | // NewMock returns new mockStringGenerator 15 | func NewMock() domain.StringGenerator { 16 | return &mockStringGenerator{} 17 | } 18 | 19 | // Generate returns pre-set string 20 | func (m *mockStringGenerator) Generate() string { 21 | return preset 22 | } 23 | -------------------------------------------------------------------------------- /infrastructure/auth/jwt_mock.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/bccfilkom/drophere-go/domain" 8 | ) 9 | 10 | type jwtAuthenticatorMock struct{} 11 | 12 | // NewJWTMock func 13 | func NewJWTMock() domain.Authenticator { 14 | return &jwtAuthenticatorMock{} 15 | } 16 | 17 | // Authenticate mock 18 | func (j *jwtAuthenticatorMock) Authenticate(u *domain.User) (*domain.UserCredentials, error) { 19 | t := time.Now().Add(time.Hour) 20 | return &domain.UserCredentials{ 21 | Token: "user_token_"+strconv.Itoa(int(u.ID)), 22 | Expiry: &t, 23 | }, nil 24 | } -------------------------------------------------------------------------------- /infrastructure/database/mysql/mysql.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/jinzhu/gorm" 7 | ) 8 | 9 | // New creates new GORM database instance 10 | func New(dsn string) (*gorm.DB, error) { 11 | db, err := gorm.Open("mysql", dsn) 12 | if err != nil { 13 | return nil, err 14 | } 15 | 16 | db = db. 17 | Set("gorm:table_options", "DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_general_ci ENGINE=InnoDB"). 18 | Set("gorm:auto_preload", false) 19 | 20 | db.DB().SetMaxIdleConns(10) 21 | db.DB().SetMaxOpenConns(100) 22 | db.DB().SetConnMaxLifetime(time.Minute) 23 | 24 | // db = db.Set("gorm:auto_preload", true) 25 | 26 | return db, nil 27 | } 28 | -------------------------------------------------------------------------------- /files/template/html/request_password_recovery.html: -------------------------------------------------------------------------------- 1 | {{define "request_password_recovery_html"}} 2 | 3 | Recover Password 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 |
Password Recovery Token{{.Token}}
13 | {{.ResetPasswordLink}} 14 |
17 | {{end}} -------------------------------------------------------------------------------- /config.sample.yaml: -------------------------------------------------------------------------------- 1 | app: 2 | debug: false 3 | storageRootDirectoryName: "drophere" 4 | templatePath: "files/template" 5 | passwordRecovery: 6 | tokenExpiryDuration: 5 # in minutes 7 | webURL: "http://localhost:3000/reset-password" 8 | mailer: 9 | email: "bot@comeapp.id" 10 | name: "Drophere Bot" 11 | 12 | db: 13 | dsn: "user:pwd@tcp(localhost:3306)/drophere?charset=utf8mb4&collation=utf8mb4_general_ci&parseTime=true" 14 | 15 | jwt: 16 | secret: "please-put-your-secret-key-here" 17 | duration: 8760 # in hours (token duration) 18 | signingAlgorithm: HS256 19 | 20 | mailer: 21 | mailtrap: 22 | host: "smtp.mailtrap.io" 23 | port: 587 24 | username: "" 25 | password: 26 | encryption: "tls" 27 | sendgrid: 28 | apiKey: "" 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stretch-slim 2 | ARG SOURCE_LOCATION=./build 3 | EXPOSE 8888 4 | 5 | # install dependencies 6 | RUN apt-get update && \ 7 | apt-get install -y --no-install-recommends \ 8 | apt-transport-https \ 9 | curl \ 10 | ca-certificates \ 11 | && apt-get clean \ 12 | && apt-get autoremove \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | # create new user 16 | RUN useradd --create-home drophere 17 | 18 | # create new directory 19 | RUN mkdir -p /home/drophere/drophere-service 20 | 21 | # specify directory 22 | WORKDIR /home/drophere/drophere-service 23 | COPY ${SOURCE_LOCATION} . 24 | 25 | # change owner to user "drophere" 26 | RUN chown -R drophere:drophere . 27 | 28 | USER drophere 29 | RUN chmod +x drophere-service 30 | 31 | CMD ["./drophere-service"] 32 | -------------------------------------------------------------------------------- /infrastructure/hasher/bcrypt.go: -------------------------------------------------------------------------------- 1 | package hasher 2 | 3 | import ( 4 | "github.com/bccfilkom/drophere-go/domain" 5 | "golang.org/x/crypto/bcrypt" 6 | ) 7 | 8 | type bcryptHasher struct { 9 | cost int 10 | } 11 | 12 | // NewBcryptHasher bcrypt hasher that implements Hasher interface 13 | func NewBcryptHasher() domain.Hasher { 14 | return &bcryptHasher{ 15 | cost: 12, 16 | } 17 | } 18 | 19 | // Hash implementation 20 | func (b *bcryptHasher) Hash(s string) (string, error) { 21 | hashed, err := bcrypt.GenerateFromPassword([]byte(s), b.cost) 22 | if err != nil { 23 | return "", err 24 | } 25 | return string(hashed), nil 26 | } 27 | 28 | // Verify implementation 29 | func (b *bcryptHasher) Verify(hashed, plain string) bool { 30 | return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(plain)) == nil 31 | } 32 | -------------------------------------------------------------------------------- /infrastructure/mailer/mailtrap.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "github.com/bccfilkom/drophere-go/domain" 5 | 6 | "gopkg.in/gomail.v2" 7 | ) 8 | 9 | type mailtrap struct { 10 | dialer *gomail.Dialer 11 | } 12 | 13 | // NewMailtrap returns new mailtrap instance 14 | func NewMailtrap(user, password string) domain.Mailer { 15 | dialer := gomail.NewDialer("smtp.mailtrap.io", 587, user, password) 16 | return &mailtrap{dialer} 17 | } 18 | 19 | // Send sends the email to mailtrap server 20 | func (m *mailtrap) Send(from, to domain.MailAddress, subject, messagePlain, messageHTML string) error { 21 | msg := gomail.NewMessage() 22 | msg.SetAddressHeader("From", from.Address, from.Name) 23 | msg.SetAddressHeader("To", to.Address, to.Name) 24 | msg.SetHeader("Subject", subject) 25 | msg.SetBody("text/html", messageHTML) 26 | msg.AddAlternative("text/plain", messagePlain) 27 | 28 | return m.dialer.DialAndSend(msg) 29 | } 30 | -------------------------------------------------------------------------------- /infrastructure/storageprovider/mock.go: -------------------------------------------------------------------------------- 1 | package storageprovider 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/bccfilkom/drophere-go/domain" 7 | ) 8 | 9 | var sharedAccountInfo domain.StorageProviderAccountInfo 10 | 11 | type mock struct{} 12 | 13 | // SetSharedAccountInfo set the sharedAccountInfo object 14 | func SetSharedAccountInfo(accountInfo domain.StorageProviderAccountInfo) { 15 | sharedAccountInfo = accountInfo 16 | } 17 | 18 | // NewMock returns new mock 19 | func NewMock() domain.StorageProviderService { 20 | return &mock{} 21 | } 22 | 23 | // ID returns provider ID 24 | func (m *mock) ID() uint { 25 | return 1 26 | } 27 | 28 | // AccountInfo mock 29 | func (m *mock) AccountInfo(cred domain.StorageProviderCredential) (domain.StorageProviderAccountInfo, error) { 30 | return sharedAccountInfo, nil 31 | } 32 | 33 | // Upload mock 34 | func (m *mock) Upload(cred domain.StorageProviderCredential, file io.Reader, fileName, slug string) error { 35 | return nil 36 | } -------------------------------------------------------------------------------- /files/sql/migration_20190904.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `user_storage_credentials` ( 2 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 3 | `user_id` int(10) unsigned NOT NULL, 4 | `provider_id` int(10) unsigned NOT NULL, 5 | `provider_credential` varchar(255) NOT NULL, 6 | `email` varchar(255) NOT NULL DEFAULT '', 7 | `photo` varchar(255) NOT NULL DEFAULT '', 8 | PRIMARY KEY (`id`), 9 | UNIQUE KEY `user_provider_unique` (`user_id`, `provider_id`), 10 | KEY `usc_user_id_users_id_foreign` (`user_id`), 11 | CONSTRAINT `usc_user_id_users_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE 12 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 13 | 14 | ALTER TABLE `links` 15 | ADD `user_storage_credential_id` int(10) unsigned NULL, 16 | ADD KEY `links_usc_id_foreign` (`user_storage_credential_id`), 17 | ADD CONSTRAINT `links_usc_id_foreign` FOREIGN KEY (`user_storage_credential_id`) REFERENCES `user_storage_credentials` (`id`) ON DELETE SET NULL ON UPDATE CASCADE; -------------------------------------------------------------------------------- /files/sql/db.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `users` ( 2 | `id` int unsigned NOT NULL AUTO_INCREMENT, 3 | `email` varchar(255) NOT NULL, 4 | `name` varchar(255) NOT NULL, 5 | `dropbox_token` varchar(255) DEFAULT NULL, 6 | `drive_token` varchar(255) DEFAULT NULL, 7 | PRIMARY KEY (`id`), 8 | UNIQUE KEY `email` (`email`) 9 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 10 | 11 | CREATE TABLE `links` ( 12 | `id` int unsigned NOT NULL AUTO_INCREMENT, 13 | `user_id` int unsigned NOT NULL, 14 | `title` varchar(255) CHARACTER SET utf8mb4 NOT NULL, 15 | `password` varchar(255) NOT NULL, 16 | `slug` varchar(255) NOT NULL, 17 | `description` text CHARACTER SET utf8mb4 NOT NULL, 18 | `deadline` datetime NULL DEFAULT NULL, 19 | PRIMARY KEY (`id`), 20 | UNIQUE KEY `slug` (`slug`), 21 | KEY `links_user_id_users_id_foreign` (`user_id`), 22 | CONSTRAINT `links_user_id_users_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE 23 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 24 | -------------------------------------------------------------------------------- /infrastructure/mailer/mock.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import "github.com/bccfilkom/drophere-go/domain" 4 | 5 | // MockMessage is for testing purpose 6 | type MockMessage struct { 7 | From string 8 | To string 9 | Title string 10 | MessagePlain string 11 | MessageHTML string 12 | } 13 | 14 | // MockMessages is an in-memory storage for testing purpose 15 | var MockMessages []MockMessage 16 | 17 | func init() { 18 | MockMessages = make([]MockMessage, 0) 19 | } 20 | 21 | // ClearMessages reset the MockMessages 22 | func ClearMessages() { 23 | MockMessages = make([]MockMessage, 0) 24 | } 25 | 26 | type mockMailer struct{} 27 | 28 | // NewMockMailer returns new mockMailer instance 29 | func NewMockMailer() domain.Mailer { 30 | return &mockMailer{} 31 | } 32 | 33 | // Send sends the email to mockMailer server 34 | func (m *mockMailer) Send(from, to domain.MailAddress, subject, messagePlain, messageHTML string) error { 35 | MockMessages = append(MockMessages, MockMessage{from.Address, to.Address, subject, messagePlain, messageHTML}) 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /infrastructure/database/inmemory/user.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import "github.com/bccfilkom/drophere-go/domain" 4 | 5 | type userRepository struct { 6 | db *DB 7 | } 8 | 9 | // NewUserRepository func 10 | func NewUserRepository(db *DB) domain.UserRepository { 11 | return &userRepository{db} 12 | } 13 | 14 | // Create implementation 15 | func (repo *userRepository) Create(user *domain.User) (*domain.User, error) { 16 | return repo.db.CreateUser(user) 17 | } 18 | 19 | // FindByEmail implementation 20 | func (repo *userRepository) FindByEmail(email string) (*domain.User, error) { 21 | return repo.db.FindUserByEmail(email) 22 | } 23 | 24 | // FindByID implementation 25 | func (repo *userRepository) FindByID(id uint) (*domain.User, error) { 26 | return repo.db.FindUserByID(id) 27 | } 28 | 29 | // Update implementation 30 | func (repo *userRepository) Update(u *domain.User) (*domain.User, error) { 31 | updated := false 32 | for i := range repo.db.users { 33 | if repo.db.users[i].ID == u.ID { 34 | repo.db.users[i] = *u 35 | updated = true 36 | break 37 | } 38 | } 39 | 40 | if !updated { 41 | repo.db.users = append(repo.db.users, *u) 42 | } 43 | return u, nil 44 | } 45 | -------------------------------------------------------------------------------- /infrastructure/mailer/sendgrid.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/bccfilkom/drophere-go/domain" 7 | 8 | sendgridClient "github.com/sendgrid/sendgrid-go" 9 | mailHelper "github.com/sendgrid/sendgrid-go/helpers/mail" 10 | ) 11 | 12 | type sendgrid struct { 13 | apiKey string 14 | debug bool 15 | } 16 | 17 | // NewSendgrid returns new sendgrid instance 18 | func NewSendgrid(apiKey string, debug bool) domain.Mailer { 19 | return &sendgrid{apiKey, debug} 20 | } 21 | 22 | // Send sends the email to sendgrid server 23 | func (s *sendgrid) Send(from, to domain.MailAddress, subject, messagePlain, messageHTML string) error { 24 | 25 | f := mailHelper.NewEmail( 26 | from.Name, 27 | from.Address, 28 | ) 29 | t := mailHelper.NewEmail( 30 | to.Name, 31 | to.Address, 32 | ) 33 | msg := mailHelper.NewSingleEmail(f, subject, t, messagePlain, messageHTML) 34 | c := sendgridClient.NewSendClient(s.apiKey) 35 | 36 | // log the response 37 | resp, err := c.Send(msg) 38 | if s.debug { 39 | log.Println("Sendgrid: StatusCode:", resp.StatusCode) 40 | log.Println("Sendgrid: Body:", resp.Body) 41 | log.Println("Sendgrid: Headers:", resp.Headers) 42 | } 43 | 44 | return err 45 | 46 | } 47 | -------------------------------------------------------------------------------- /domain/user_storage.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "errors" 4 | 5 | var ( 6 | // ErrUserStorageCredentialNotFound error 7 | ErrUserStorageCredentialNotFound = errors.New("User Storage Credential not found") 8 | ) 9 | 10 | // UserStorageCredential stores information about user's account on 11 | // a storage provider (e.g. Dropbox) 12 | type UserStorageCredential struct { 13 | ID uint 14 | UserID uint 15 | User User 16 | ProviderID uint 17 | ProviderCredential string 18 | Email string 19 | Photo string 20 | } 21 | 22 | // UserStorageCredentialFilters stores filters to be used by 23 | // Find function in UserStorageCredentialRepository 24 | type UserStorageCredentialFilters struct { 25 | UserIDs []uint 26 | ProviderIDs []uint 27 | } 28 | 29 | // UserStorageCredentialRepository abstraction 30 | type UserStorageCredentialRepository interface { 31 | Find(filters UserStorageCredentialFilters, withUserRelation bool) ([]UserStorageCredential, error) 32 | FindByID(id uint, withUserRelation bool) (UserStorageCredential, error) 33 | Create(cred UserStorageCredential) (UserStorageCredential, error) 34 | Update(cred UserStorageCredential) (UserStorageCredential, error) 35 | Delete(cred UserStorageCredential) error 36 | } 37 | -------------------------------------------------------------------------------- /CONTRIBUTING.MD: -------------------------------------------------------------------------------- 1 | # Contributing guide 2 | 3 | Interested in contributing to this project? As an open source project, we'd really appreciate any help and contributions! 4 | 5 | ## Contribute code for this project 6 | Here's how to submit a Pull Request (PR): 7 | 8 | 1. [Fork this repository on GitHub][fork]. 9 | * If you have forked this repository, please follow the following guide. 10 | * Setup an [Upstream remote][configure-upstream] to this repository 11 | `https://github.com/bccfilkom/drophere-backend.git` 12 | * [Sync your fork][sync-fork] with the upstream. 13 | 2. Clone your fork of the repository to your local computer. 14 | 3. Create a branch with descriptive name to work (i.e., `git checkout -b feature/your-feature-name`). 15 | 4. Make changes, commit them, and push the branch to your repository fork. 16 | 5. Writing unit test would be appreciated. 17 | 6. [Submit a pull request][pull-req] to the master branch. 18 | 7. Address review comments if any. 19 | 20 | [fork]: https://help.github.com/articles/fork-a-repo 21 | [configure-upstream]: https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/configuring-a-remote-for-a-fork 22 | [sync-fork]: https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/syncing-a-fork 23 | [pull-req]: https://help.github.com/articles/using-pull-requests 24 | -------------------------------------------------------------------------------- /models_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/99designs/gqlgen, DO NOT EDIT. 2 | 3 | package drophere_go 4 | 5 | import ( 6 | "time" 7 | ) 8 | 9 | type Link struct { 10 | ID int `json:"id"` 11 | Title string `json:"title"` 12 | IsProtected bool `json:"isProtected"` 13 | Slug *string `json:"slug"` 14 | Description *string `json:"description"` 15 | Deadline *time.Time `json:"deadline"` 16 | StorageProvider *StorageProvider `json:"storageProvider"` 17 | } 18 | 19 | type Message struct { 20 | Message string `json:"message"` 21 | } 22 | 23 | type StorageProvider struct { 24 | ID int `json:"id"` 25 | ProviderID int `json:"providerId"` 26 | Email string `json:"email"` 27 | Photo string `json:"photo"` 28 | } 29 | 30 | type Token struct { 31 | LoginToken string `json:"loginToken"` 32 | } 33 | 34 | type User struct { 35 | ID int `json:"id"` 36 | Email string `json:"email"` 37 | Name string `json:"name"` 38 | DropboxAuthorized bool `json:"dropboxAuthorized"` 39 | DropboxEmail *string `json:"dropboxEmail"` 40 | DropboxAvatar *string `json:"dropboxAvatar"` 41 | ConnectedStorageProviders []*StorageProvider `json:"connectedStorageProviders"` 42 | } 43 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: docker:stable 2 | 3 | variables: 4 | DOCKER_DRIVER: overlay2 5 | VERSION: 0.1.0 6 | 7 | stages: 8 | - build 9 | - package 10 | 11 | compile binary: 12 | stage: build 13 | image: golang:1.12.6-stretch 14 | before_script: 15 | - go version 16 | # installing dependencies 17 | - go get ./... 18 | variables: 19 | CGO_ENABLED: "1" 20 | script: 21 | - ls -hal 22 | - go test -v -coverprofile cover.out ./... 23 | - mkdir build 24 | - go build -v -tags netgo -o build/drophere-service server/*.go 25 | - cp -vr files build/ 26 | - ls -hal build 27 | artifacts: 28 | paths: 29 | - build/drophere-service 30 | - build/files 31 | only: 32 | - develop 33 | - tags 34 | 35 | package staging: 36 | stage: package 37 | services: 38 | - docker:dind 39 | variables: 40 | IMAGE_TAG: $CI_REGISTRY_IMAGE:staging-$VERSION 41 | IMAGE_TAG_LATEST: $CI_REGISTRY_IMAGE:staging-latest 42 | before_script: 43 | - docker info 44 | script: 45 | # Copy configuration file 46 | - cp -vr config.sample.yaml build/config.yaml 47 | - ls -hal build 48 | # Build Docker image 49 | - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY 50 | - docker build -t $IMAGE_TAG . 51 | - docker tag $IMAGE_TAG $IMAGE_TAG_LATEST 52 | - docker push $CI_REGISTRY_IMAGE 53 | artifacts: 54 | paths: 55 | - build/config.yaml 56 | only: 57 | - develop 58 | -------------------------------------------------------------------------------- /infrastructure/database/mysql/user.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "github.com/bccfilkom/drophere-go/domain" 5 | "github.com/jinzhu/gorm" 6 | ) 7 | 8 | type userRepository struct { 9 | db *gorm.DB 10 | } 11 | 12 | // NewUserRepository func 13 | func NewUserRepository(db *gorm.DB) domain.UserRepository { 14 | return &userRepository{db} 15 | } 16 | 17 | // Create implementation 18 | func (repo *userRepository) Create(user *domain.User) (*domain.User, error) { 19 | if err := repo.db.Create(user).Error; err != nil { 20 | return nil, err 21 | } 22 | return user, nil 23 | } 24 | 25 | // FindByEmail implementation 26 | func (repo *userRepository) FindByEmail(email string) (*domain.User, error) { 27 | user := domain.User{} 28 | if q := repo.db. 29 | Where("`email` = ? ", email). 30 | Find(&user); q.RecordNotFound() { 31 | return nil, domain.ErrUserNotFound 32 | } else if q.Error != nil { 33 | return nil, q.Error 34 | } 35 | return &user, nil 36 | } 37 | 38 | // FindByID implementation 39 | func (repo *userRepository) FindByID(id uint) (*domain.User, error) { 40 | user := domain.User{} 41 | if q := repo.db. 42 | Find(&user, id); q.RecordNotFound() { 43 | return nil, domain.ErrUserNotFound 44 | } else if q.Error != nil { 45 | return nil, q.Error 46 | } 47 | return &user, nil 48 | } 49 | 50 | // Update implementation 51 | func (repo *userRepository) Update(u *domain.User) (*domain.User, error) { 52 | if err := repo.db.Save(u).Error; err != nil { 53 | return nil, err 54 | } 55 | return u, nil 56 | } 57 | -------------------------------------------------------------------------------- /domain/storage_provider.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | ) 7 | 8 | var ( 9 | // ErrStorageProviderInvalid error 10 | ErrStorageProviderInvalid = errors.New("Invalid Storage Provider ID") 11 | ) 12 | 13 | // StorageProvider domain model 14 | // type StorageProvider struct { 15 | // ID uint 16 | // Name string 17 | // } 18 | 19 | // StorageProviderCredential stores data needed to access 20 | // storage provider API 21 | type StorageProviderCredential struct { 22 | UserAccessToken string 23 | } 24 | 25 | // StorageProviderAccountInfo domain model 26 | type StorageProviderAccountInfo struct { 27 | Email string 28 | Photo string 29 | } 30 | 31 | // StorageProviderService abstraction 32 | type StorageProviderService interface { 33 | ID() uint 34 | AccountInfo(creds StorageProviderCredential) (StorageProviderAccountInfo, error) 35 | Upload(creds StorageProviderCredential, file io.Reader, fileName, slug string) error 36 | } 37 | 38 | // StorageProviderPool stores a collection of Storage Provider Service 39 | // with provider ID as the key 40 | type StorageProviderPool struct { 41 | pool map[uint]StorageProviderService 42 | } 43 | 44 | // Get returns a StorageProviderService instance identified by its provider ID 45 | func (p *StorageProviderPool) Get(providerID uint) (StorageProviderService, error) { 46 | sps, ok := p.pool[providerID] 47 | if !ok { 48 | return nil, ErrStorageProviderInvalid 49 | } 50 | return sps, nil 51 | } 52 | 53 | // Register stores a StorageProviderService instance to the pool 54 | func (p *StorageProviderPool) Register(sps StorageProviderService) { 55 | if p.pool == nil { 56 | p.pool = make(map[uint]StorageProviderService) 57 | } 58 | 59 | if sps != nil { 60 | p.pool[sps.ID()] = sps 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /domain/link.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | var ( 9 | // ErrLinkDuplicatedSlug error 10 | ErrLinkDuplicatedSlug = errors.New("Duplicated slug") 11 | // ErrLinkInvalidPassword error 12 | ErrLinkInvalidPassword = errors.New("Invalid password") 13 | // ErrLinkNotFound error 14 | ErrLinkNotFound = errors.New("Not found") 15 | ) 16 | 17 | // Link domain model 18 | type Link struct { 19 | ID uint 20 | UserID uint 21 | User *User 22 | Title string 23 | Password string 24 | Slug string 25 | Deadline *time.Time 26 | Description string 27 | UserStorageCredentialID *uint 28 | UserStorageCredential *UserStorageCredential 29 | } 30 | 31 | // IsProtected checks if the link is protected with password 32 | func (l *Link) IsProtected() bool { 33 | return l.Password != "" 34 | } 35 | 36 | // LinkService abstraction 37 | type LinkService interface { 38 | CheckLinkPassword(l *Link, password string) bool 39 | CreateLink(title, slug, description string, deadline *time.Time, password *string, user *User, providerID *uint) (*Link, error) 40 | UpdateLink(id uint, title, slug string, description *string, deadline *time.Time, password *string, providerID *uint) (*Link, error) 41 | DeleteLink(id uint) error 42 | FetchLink(id uint) (*Link, error) 43 | FindLinkBySlug(slug string) (*Link, error) 44 | ListLinks(userID uint) ([]Link, error) 45 | } 46 | 47 | // LinkRepository abstraction 48 | type LinkRepository interface { 49 | Create(l *Link) (*Link, error) 50 | Delete(l *Link) error 51 | FindByID(id uint) (*Link, error) 52 | FindBySlug(slug string) (*Link, error) 53 | ListByUser(userID uint) ([]Link, error) 54 | Update(l *Link) (*Link, error) 55 | } 56 | -------------------------------------------------------------------------------- /schema.graphql: -------------------------------------------------------------------------------- 1 | scalar Time 2 | 3 | type StorageProvider { 4 | id: Int! 5 | providerId: Int! 6 | email: String! 7 | photo: String! 8 | } 9 | 10 | type User { 11 | id: Int! 12 | email: String! 13 | name: String! 14 | dropboxAuthorized: Boolean! 15 | dropboxEmail: String 16 | dropboxAvatar: String 17 | connectedStorageProviders: [StorageProvider!]! 18 | } 19 | type Token { 20 | loginToken: String! 21 | #expiry: Time 22 | } 23 | type Link { 24 | id: Int! 25 | title: String! 26 | isProtected: Boolean! 27 | slug: String 28 | description: String 29 | deadline: Time 30 | storageProvider: StorageProvider 31 | ## storageProvider is null if the link is not connected to any storage provider 32 | } 33 | type Message { 34 | message: String! 35 | } 36 | 37 | # the schema allows the following query: 38 | type Query { 39 | ## TODO: change links type below to [Link!]! 40 | links: [Link] 41 | me: User 42 | link(slug: String!): Link 43 | } 44 | type Mutation { 45 | # Register new user 46 | register(email: String!, password: String!, name: String!): Token 47 | login(email: String!, password: String!): Token 48 | requestPasswordRecovery(email: String!): Message 49 | recoverPassword(email: String!, recoverToken: String!, newPassword: String!): Token 50 | updatePassword(oldPassword: String!, newPassword: String!): Message 51 | updateProfile(newName: String!): Message 52 | connectStorageProvider(providerId: Int!, providerToken: String!): Message 53 | disconnectStorageProvider(providerId: Int!): Message 54 | createLink(title: String!, slug: String!, description: String, deadline: Time, password: String, providerId: Int): Link 55 | updateLink(linkId: Int!, title: String!, slug: String!, description: String, deadline: Time, password: String, providerId: Int): Link 56 | deleteLink(linkId: Int!): Message 57 | checkLinkPassword(linkId: Int!, password: String!): Message 58 | } 59 | 60 | -------------------------------------------------------------------------------- /domain/user.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | var ( 9 | // ErrUserDuplicated error 10 | ErrUserDuplicated = errors.New("Duplicated email") 11 | // ErrUserInvalidPassword error 12 | ErrUserInvalidPassword = errors.New("Invalid password") 13 | // ErrUserNotFound error 14 | ErrUserNotFound = errors.New("User not found") 15 | // ErrUserPasswordRecoveryTokenExpired error 16 | ErrUserPasswordRecoveryTokenExpired = errors.New("Password recovery token is expired") 17 | ) 18 | 19 | // User model 20 | type User struct { 21 | ID uint 22 | Email string 23 | Name string 24 | Password string 25 | DropboxToken *string 26 | DriveToken *string 27 | RecoverPasswordToken *string 28 | RecoverPasswordTokenExpiry *time.Time 29 | } 30 | 31 | // UserCredentials model 32 | type UserCredentials struct { 33 | Token string 34 | Expiry *time.Time 35 | } 36 | 37 | // UserService abstraction 38 | type UserService interface { 39 | Register(email, name, password string) (*User, error) 40 | Auth(email, password string) (*UserCredentials, error) 41 | Update(userID uint, name, password, oldPassword *string) (*User, error) 42 | ConnectStorageProvider(userID, providerID uint, providerCredential string) error 43 | DisconnectStorageProvider(userID, providerID uint) error 44 | ListStorageProviders(userID uint) ([]UserStorageCredential, error) 45 | UpdateStorageToken(userID uint, dropboxToken *string) (*User, error) 46 | RequestPasswordRecovery(email string) error 47 | RecoverPassword(email, token, newPassword string) error 48 | } 49 | 50 | // UserRepository abstraction 51 | type UserRepository interface { 52 | Create(u *User) (*User, error) 53 | FindByEmail(email string) (*User, error) 54 | FindByID(id uint) (*User, error) 55 | Update(u *User) (*User, error) 56 | } 57 | 58 | // Authenticator is external authentication service 59 | type Authenticator interface { 60 | Authenticate(u *User) (*UserCredentials, error) 61 | } 62 | -------------------------------------------------------------------------------- /infrastructure/database/inmemory/link.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import "github.com/bccfilkom/drophere-go/domain" 4 | 5 | type linkRepository struct { 6 | db *DB 7 | } 8 | 9 | // NewLinkRepository func 10 | func NewLinkRepository(db *DB) domain.LinkRepository { 11 | return &linkRepository{db} 12 | } 13 | 14 | // Create implementation 15 | func (repo *linkRepository) Create(l *domain.Link) (*domain.Link, error) { 16 | l.ID = uint(len(repo.db.links) + 1) 17 | repo.db.links = append(repo.db.links, *l) 18 | return l, nil 19 | } 20 | 21 | // Delete implementation 22 | func (repo *linkRepository) Delete(l *domain.Link) error { 23 | 24 | for i := range repo.db.links { 25 | if repo.db.links[i].ID == l.ID { 26 | repo.db.links = append(repo.db.links[:i], repo.db.links[i+1:]...) 27 | break 28 | } 29 | } 30 | 31 | return nil 32 | } 33 | 34 | // FindByID implementation 35 | func (repo *linkRepository) FindByID(id uint) (*domain.Link, error) { 36 | for i := range repo.db.links { 37 | if repo.db.links[i].ID == id { 38 | return &repo.db.links[i], nil 39 | } 40 | } 41 | 42 | return nil, domain.ErrLinkNotFound 43 | } 44 | 45 | // FindBySlug implementation 46 | func (repo *linkRepository) FindBySlug(slug string) (*domain.Link, error) { 47 | for i := range repo.db.links { 48 | if repo.db.links[i].Slug == slug { 49 | return &repo.db.links[i], nil 50 | } 51 | } 52 | 53 | return nil, domain.ErrLinkNotFound 54 | } 55 | 56 | // ListByUser implementation 57 | func (repo *linkRepository) ListByUser(userID uint) ([]domain.Link, error) { 58 | links := make([]domain.Link, 0, len(repo.db.links)) 59 | for _, link := range repo.db.links { 60 | if link.UserID == userID { 61 | links = append(links, link) 62 | } 63 | } 64 | 65 | return links, nil 66 | } 67 | 68 | // Update implementation 69 | func (repo *linkRepository) Update(l *domain.Link) (link *domain.Link, err error) { 70 | link = l 71 | for i := range repo.db.links { 72 | if repo.db.links[i].ID == l.ID { 73 | repo.db.links[i] = *l 74 | return 75 | } 76 | } 77 | repo.db.links = append(repo.db.links, *l) 78 | return 79 | } 80 | -------------------------------------------------------------------------------- /infrastructure/database/mysql/link.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "github.com/bccfilkom/drophere-go/domain" 5 | "github.com/jinzhu/gorm" 6 | ) 7 | 8 | type linkRepository struct { 9 | db *gorm.DB 10 | } 11 | 12 | // NewLinkRepository func 13 | func NewLinkRepository(db *gorm.DB) domain.LinkRepository { 14 | return &linkRepository{db} 15 | } 16 | 17 | // Create implementation 18 | func (repo *linkRepository) Create(l *domain.Link) (*domain.Link, error) { 19 | if err := repo.db.Create(l).Error; err != nil { 20 | return nil, err 21 | } 22 | return l, nil 23 | } 24 | 25 | // Delete implementation 26 | func (repo *linkRepository) Delete(l *domain.Link) error { 27 | return repo.db.Delete(l).Error 28 | } 29 | 30 | // FindByID implementation 31 | func (repo *linkRepository) FindByID(id uint) (*domain.Link, error) { 32 | l := domain.Link{} 33 | if q := repo.db. 34 | Preload("User"). 35 | Preload("UserStorageCredential"). 36 | Find(&l, id); q.RecordNotFound() { 37 | return nil, domain.ErrLinkNotFound 38 | } else if q.Error != nil { 39 | return nil, q.Error 40 | } 41 | 42 | return &l, nil 43 | } 44 | 45 | // FindBySlug implementation 46 | func (repo *linkRepository) FindBySlug(slug string) (*domain.Link, error) { 47 | l := domain.Link{} 48 | if q := repo.db. 49 | Where("`slug` = ? ", slug). 50 | Preload("User"). 51 | Preload("UserStorageCredential"). 52 | Find(&l); q.RecordNotFound() { 53 | return nil, domain.ErrLinkNotFound 54 | } else if q.Error != nil { 55 | return nil, q.Error 56 | } 57 | 58 | return &l, nil 59 | } 60 | 61 | // ListByUser implementation 62 | func (repo *linkRepository) ListByUser(userID uint) ([]domain.Link, error) { 63 | var links []domain.Link 64 | if err := repo.db. 65 | Where("`user_id` = ? ", userID). 66 | Preload("User"). 67 | Preload("UserStorageCredential"). 68 | Find(&links). 69 | Error; err != nil { 70 | return nil, err 71 | } 72 | 73 | return links, nil 74 | } 75 | 76 | // Update implementation 77 | func (repo *linkRepository) Update(l *domain.Link) (link *domain.Link, err error) { 78 | if err := repo.db.Save(l).Error; err != nil { 79 | return nil, err 80 | } 81 | 82 | return l, nil 83 | } 84 | -------------------------------------------------------------------------------- /domain/user/storage_provider.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "github.com/bccfilkom/drophere-go/domain" 4 | 5 | // ConnectStorageProvider implementation 6 | func (s *service) ConnectStorageProvider(userID, providerID uint, providerCredential string) error { 7 | storageProvider, err := s.storageProviderPool.Get(providerID) 8 | if err != nil { 9 | return err 10 | } 11 | 12 | u, err := s.userRepo.FindByID(userID) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | storageProviderAccount, err := storageProvider.AccountInfo( 18 | domain.StorageProviderCredential{ 19 | UserAccessToken: providerCredential, 20 | }, 21 | ) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | var cred domain.UserStorageCredential 27 | 28 | creds, err := s.userStorageCredRepo.Find(domain.UserStorageCredentialFilters{ 29 | UserIDs: []uint{u.ID}, 30 | ProviderIDs: []uint{providerID}, 31 | }, false) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if len(creds) > 0 { 37 | cred = creds[0] 38 | cred.ProviderCredential = providerCredential 39 | cred.Email = storageProviderAccount.Email 40 | cred.Photo = storageProviderAccount.Photo 41 | cred, err = s.userStorageCredRepo.Update(cred) 42 | } else { 43 | cred, err = s.userStorageCredRepo.Create(domain.UserStorageCredential{ 44 | UserID: u.ID, 45 | ProviderID: providerID, 46 | ProviderCredential: providerCredential, 47 | Email: storageProviderAccount.Email, 48 | Photo: storageProviderAccount.Photo, 49 | }) 50 | } 51 | 52 | return err 53 | 54 | } 55 | 56 | // DisconnectStorageProvider implementation 57 | func (s *service) DisconnectStorageProvider(userID, providerID uint) error { 58 | storageProvider, err := s.storageProviderPool.Get(providerID) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | u, err := s.userRepo.FindByID(userID) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | creds, err := s.userStorageCredRepo.Find(domain.UserStorageCredentialFilters{ 69 | UserIDs: []uint{u.ID}, 70 | ProviderIDs: []uint{storageProvider.ID()}, 71 | }, false) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | if len(creds) > 0 { 77 | err = s.userStorageCredRepo.Delete(creds[0]) 78 | if err != nil { 79 | return err 80 | } 81 | } 82 | 83 | return nil 84 | 85 | } 86 | 87 | // ListStorageProviders implementation 88 | func (s *service) ListStorageProviders(userID uint) ([]domain.UserStorageCredential, error) { 89 | return s.userStorageCredRepo.Find(domain.UserStorageCredentialFilters{ 90 | UserIDs: []uint{userID}, 91 | }, false) 92 | } 93 | -------------------------------------------------------------------------------- /infrastructure/database/mysql/user_storage.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "github.com/bccfilkom/drophere-go/domain" 5 | "github.com/jinzhu/gorm" 6 | ) 7 | 8 | type userStorageCredentialRepository struct { 9 | db *gorm.DB 10 | } 11 | 12 | // NewUserStorageCredentialRepository func 13 | func NewUserStorageCredentialRepository(db *gorm.DB) domain.UserStorageCredentialRepository { 14 | return &userStorageCredentialRepository{db} 15 | } 16 | 17 | // Find implementation 18 | func (repo *userStorageCredentialRepository) Find(filters domain.UserStorageCredentialFilters, withUserRelation bool) ([]domain.UserStorageCredential, error) { 19 | var ( 20 | creds []domain.UserStorageCredential 21 | dbQuery = repo.db 22 | ) 23 | 24 | if withUserRelation { 25 | dbQuery = dbQuery.Preload("User") 26 | } 27 | 28 | if filters.UserIDs != nil && len(filters.UserIDs) > 0 { 29 | dbQuery = dbQuery.Where("`user_id` IN (?)", filters.UserIDs) 30 | } 31 | 32 | if filters.ProviderIDs != nil && len(filters.ProviderIDs) > 0 { 33 | dbQuery = dbQuery.Where("`provider_id` IN (?)", filters.ProviderIDs) 34 | } 35 | 36 | err := dbQuery.Find(&creds).Error 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return creds, nil 42 | } 43 | 44 | // FindByID implementation 45 | func (repo *userStorageCredentialRepository) FindByID(id uint, withUserRelation bool) (domain.UserStorageCredential, error) { 46 | var ( 47 | cred domain.UserStorageCredential 48 | dbQuery = repo.db 49 | ) 50 | 51 | if withUserRelation { 52 | dbQuery = dbQuery.Preload("User") 53 | } 54 | 55 | if q := dbQuery.Find(&cred, id); q.RecordNotFound() { 56 | return cred, domain.ErrUserStorageCredentialNotFound 57 | } else if q.Error != nil { 58 | return cred, q.Error 59 | } 60 | 61 | return cred, nil 62 | } 63 | 64 | // Create implementation 65 | func (repo *userStorageCredentialRepository) Create(cred domain.UserStorageCredential) (domain.UserStorageCredential, error) { 66 | err := repo.db.Create(&cred).Error 67 | if err != nil { 68 | return domain.UserStorageCredential{}, err 69 | } 70 | 71 | return cred, nil 72 | } 73 | 74 | // Update implementation 75 | func (repo *userStorageCredentialRepository) Update(cred domain.UserStorageCredential) (domain.UserStorageCredential, error) { 76 | err := repo.db.Save(&cred).Error 77 | if err != nil { 78 | return domain.UserStorageCredential{}, err 79 | } 80 | 81 | return cred, nil 82 | } 83 | 84 | // Delete implementation 85 | func (repo *userStorageCredentialRepository) Delete(cred domain.UserStorageCredential) error { 86 | return repo.db.Delete(&cred).Error 87 | } 88 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bccfilkom/drophere-go 2 | 3 | go 1.12 4 | 5 | require ( 6 | cloud.google.com/go v0.39.0 // indirect 7 | github.com/99designs/gqlgen v0.9.0 8 | github.com/OneOfOne/xxhash v1.2.5 // indirect 9 | github.com/coreos/etcd v3.3.13+incompatible // indirect 10 | github.com/coreos/go-semver v0.3.0 // indirect 11 | github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 // indirect 12 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 13 | github.com/dgryski/go-sip13 v0.0.0-20190329191031-25c5027a8c7b // indirect 14 | github.com/go-chi/chi v4.0.2+incompatible 15 | github.com/go-sql-driver/mysql v1.4.1 16 | github.com/gofrs/uuid v3.2.0+incompatible 17 | github.com/golang/mock v1.3.1 // indirect 18 | github.com/google/go-cmp v0.3.0 // indirect 19 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f // indirect 20 | github.com/hashicorp/golang-lru v0.5.1 // indirect 21 | github.com/jinzhu/gorm v1.9.8 22 | github.com/kisielk/errcheck v1.2.0 // indirect 23 | github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect 24 | github.com/kr/pty v1.1.4 // indirect 25 | github.com/lib/pq v1.1.1 // indirect 26 | github.com/magiconair/properties v1.8.1 // indirect 27 | github.com/pelletier/go-toml v1.4.0 // indirect 28 | github.com/prometheus/common v0.4.1 // indirect 29 | github.com/prometheus/procfs v0.0.0-20190523193104-a7aeb8df3389 // indirect 30 | github.com/prometheus/tsdb v0.8.0 // indirect 31 | github.com/rogpeppe/fastuuid v1.1.0 // indirect 32 | github.com/rs/cors v1.6.0 33 | github.com/sendgrid/rest v2.4.1+incompatible // indirect 34 | github.com/sendgrid/sendgrid-go v3.5.0+incompatible 35 | github.com/sirupsen/logrus v1.4.2 // indirect 36 | github.com/spaolacci/murmur3 v1.1.0 // indirect 37 | github.com/spf13/afero v1.2.2 // indirect 38 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 39 | github.com/spf13/viper v1.4.0 40 | github.com/stretchr/objx v0.2.0 // indirect 41 | github.com/stretchr/testify v1.4.0 42 | github.com/vektah/gqlparser v1.1.2 43 | golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f 44 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522 // indirect 45 | golang.org/x/image v0.0.0-20190523035834-f03afa92d3ff // indirect 46 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422 // indirect 47 | golang.org/x/mobile v0.0.0-20190509164839-32b2708ab171 // indirect 48 | golang.org/x/oauth2 v0.0.0-20190523182746-aaccbc9213b0 // indirect 49 | golang.org/x/sys v0.0.0-20190528183647-3626398d7749 // indirect 50 | golang.org/x/text v0.3.2 // indirect 51 | golang.org/x/tools v0.0.0-20190529010454-aa71c3f32488 // indirect 52 | google.golang.org/appengine v1.6.0 // indirect 53 | google.golang.org/genproto v0.0.0-20190522204451-c2c4e71fbf69 // indirect 54 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df 55 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc // indirect 56 | ) 57 | -------------------------------------------------------------------------------- /infrastructure/database/inmemory/mem.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/bccfilkom/drophere-go/domain" 7 | ) 8 | 9 | // DB struct 10 | type DB struct { 11 | users []domain.User 12 | links []domain.Link 13 | userStorageCreds []domain.UserStorageCredential 14 | } 15 | 16 | // New func 17 | func New() *DB { 18 | db := &DB{} 19 | db.populate() 20 | return db 21 | } 22 | 23 | func str2ptr(s string) *string { 24 | return &s 25 | } 26 | 27 | func time2ptr(t time.Time) *time.Time { 28 | return &t 29 | } 30 | 31 | func (db *DB) populate() { 32 | db.users = []domain.User{ 33 | {ID: 1, Email: "user@drophere.link", Name: "User", Password: "123456", DropboxToken: nil, DriveToken: nil}, 34 | {ID: 357, Email: "user_357@drophere.link", Name: "User 357", Password: "123456", DropboxToken: nil, DriveToken: nil}, 35 | { 36 | ID: 6631, 37 | Email: "reset+pwd+expired_token@drophere.link", 38 | Name: "Token is set but expired", 39 | Password: "123456", 40 | RecoverPasswordToken: str2ptr("expired_recover_password_token"), 41 | RecoverPasswordTokenExpiry: time2ptr(time.Now().Add(time.Minute * -30)), 42 | }, 43 | { 44 | ID: 12368, 45 | Email: "reset+pwd@drophere.link", 46 | Name: "Token is set", 47 | Password: "123456", 48 | RecoverPasswordToken: str2ptr("recover_password_token"), 49 | RecoverPasswordTokenExpiry: time2ptr(time.Now().Add(time.Minute * 30)), 50 | }, 51 | } 52 | 53 | db.links = []domain.Link{ 54 | {ID: 1, UserID: 1, User: &db.users[0], Title: "Drop file here", Slug: "drop-here", Password: "123098", Description: "drop a file here"}, 55 | {ID: 2, UserID: 1, User: &db.users[0], Title: "Test Link 2", Slug: "test-link-2", Password: "", Description: "no description"}, 56 | {ID: 3, UserID: 357, User: &db.users[1], Title: "Another link", Slug: "another-link", Password: "999", Description: "nil here"}, 57 | } 58 | 59 | db.userStorageCreds = []domain.UserStorageCredential{ 60 | { 61 | ID: 2000, 62 | UserID: 1, 63 | ProviderID: 1, 64 | ProviderCredential: "user_1_mock_token", 65 | Email: "user@drophere.link", 66 | Photo: "http://my.photo/user1.jpg", 67 | }, 68 | } 69 | } 70 | 71 | // FindUserByEmail func 72 | func (db *DB) FindUserByEmail(email string) (*domain.User, error) { 73 | for i, u := range db.users { 74 | if u.Email == email { 75 | return &db.users[i], nil 76 | } 77 | } 78 | return nil, domain.ErrUserNotFound 79 | } 80 | 81 | // FindUserByID func 82 | func (db *DB) FindUserByID(id uint) (*domain.User, error) { 83 | for i, u := range db.users { 84 | if u.ID == id { 85 | return &db.users[i], nil 86 | } 87 | } 88 | return nil, domain.ErrUserNotFound 89 | } 90 | 91 | // CreateUser func 92 | func (db *DB) CreateUser(u *domain.User) (*domain.User, error) { 93 | db.users = append(db.users, *u) 94 | return u, nil 95 | } 96 | -------------------------------------------------------------------------------- /infrastructure/database/inmemory/user_storage.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import "github.com/bccfilkom/drophere-go/domain" 4 | 5 | type userStorageCredentialRepository struct { 6 | db *DB 7 | } 8 | 9 | // NewUserStorageCredentialRepository func 10 | func NewUserStorageCredentialRepository(db *DB) domain.UserStorageCredentialRepository { 11 | return &userStorageCredentialRepository{db} 12 | } 13 | 14 | func isInUintSlice(u uint, slice []uint) bool { 15 | for _, el := range slice { 16 | if el == u { 17 | return true 18 | } 19 | } 20 | return false 21 | } 22 | 23 | // Find impl 24 | func (repo *userStorageCredentialRepository) Find(filters domain.UserStorageCredentialFilters, withUserRelation bool) ([]domain.UserStorageCredential, error) { 25 | creds := make([]domain.UserStorageCredential, 0) 26 | usersCache := make(map[uint]domain.User) 27 | 28 | // load users first 29 | if withUserRelation && len(filters.UserIDs) > 0 { 30 | for _, u := range repo.db.users { 31 | if isInUintSlice(u.ID, filters.UserIDs) { 32 | usersCache[u.ID] = u 33 | } 34 | } 35 | } 36 | 37 | for _, usc := range repo.db.userStorageCreds { 38 | if filters.UserIDs != nil && (len(filters.UserIDs) == 0 || 39 | !isInUintSlice(usc.UserID, filters.UserIDs)) { 40 | continue 41 | } 42 | 43 | if filters.ProviderIDs != nil && (len(filters.ProviderIDs) == 0 || 44 | !isInUintSlice(usc.ProviderID, filters.ProviderIDs)) { 45 | continue 46 | } 47 | 48 | if withUserRelation { 49 | usc.User = usersCache[usc.UserID] 50 | } 51 | 52 | creds = append(creds, usc) 53 | } 54 | 55 | return creds, nil 56 | } 57 | 58 | // FindByID impl 59 | func (repo *userStorageCredentialRepository) FindByID(id uint, withUserRelation bool) (domain.UserStorageCredential, error) { 60 | cred := domain.UserStorageCredential{} 61 | found := false 62 | for _, usc := range repo.db.userStorageCreds { 63 | if usc.ID == id { 64 | cred = usc 65 | found = true 66 | break 67 | } 68 | } 69 | 70 | if found { 71 | if withUserRelation { 72 | for _, u := range repo.db.users { 73 | if u.ID == cred.UserID { 74 | cred.User = u 75 | break 76 | } 77 | } 78 | } 79 | 80 | return cred, nil 81 | } 82 | return cred, domain.ErrUserStorageCredentialNotFound 83 | } 84 | 85 | // Create impl 86 | func (repo *userStorageCredentialRepository) Create(cred domain.UserStorageCredential) (domain.UserStorageCredential, error) { 87 | repo.db.userStorageCreds = append(repo.db.userStorageCreds, cred) 88 | return cred, nil 89 | } 90 | 91 | // Update impl 92 | func (repo *userStorageCredentialRepository) Update(cred domain.UserStorageCredential) (domain.UserStorageCredential, error) { 93 | 94 | for i := range repo.db.userStorageCreds { 95 | if repo.db.userStorageCreds[i].ID == cred.ID { 96 | repo.db.userStorageCreds[i] = cred 97 | break 98 | } 99 | } 100 | 101 | return cred, nil 102 | } 103 | 104 | // Delete impl 105 | func (repo *userStorageCredentialRepository) Delete(cred domain.UserStorageCredential) error { 106 | 107 | for i := range repo.db.userStorageCreds { 108 | if repo.db.userStorageCreds[i].ID == cred.ID { 109 | repo.db.userStorageCreds = append(repo.db.userStorageCreds[:i], repo.db.userStorageCreds[i+1:]...) 110 | break 111 | } 112 | } 113 | 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /server/fileupload.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/bccfilkom/drophere-go/domain" 11 | ) 12 | 13 | func writeError(w http.ResponseWriter, msg string) { 14 | json.NewEncoder(w).Encode(map[string]interface{}{ 15 | "errors": []map[string]string{ 16 | { 17 | "message": msg, 18 | }, 19 | }, 20 | }) 21 | } 22 | 23 | func fileUploadHandler( 24 | userSvc domain.UserService, 25 | linkSvc domain.LinkService, 26 | storageProviderPool domain.StorageProviderPool, 27 | ) http.HandlerFunc { 28 | return func(w http.ResponseWriter, r *http.Request) { 29 | w.Header().Set("Content-Type", "application/json") 30 | 31 | // get file 32 | f, fileHeader, err := r.FormFile("file") 33 | if err != nil { 34 | if debug { 35 | log.Println("read file: ", err) 36 | } 37 | writeError(w, "Invalid File") 38 | w.WriteHeader(http.StatusBadRequest) 39 | return 40 | } 41 | 42 | // get linkID 43 | linkID, err := strconv.Atoi(r.FormValue("linkId")) 44 | if err != nil { 45 | if debug { 46 | log.Println("parsing link ID: ", err) 47 | } 48 | writeError(w, "Invalid Link ID") 49 | w.WriteHeader(http.StatusBadRequest) 50 | return 51 | } 52 | 53 | // fetch link from database 54 | l, err := linkSvc.FetchLink(uint(linkID)) 55 | if err != nil { 56 | if err == domain.ErrLinkNotFound { 57 | writeError(w, err.Error()) 58 | w.WriteHeader(http.StatusNotFound) 59 | } else { 60 | if debug { 61 | log.Println("file upload: ", err) 62 | } 63 | writeError(w, "Server Error") 64 | w.WriteHeader(http.StatusInternalServerError) 65 | } 66 | return 67 | } 68 | 69 | // check if the link is connected to a Storage Provider 70 | if l.UserStorageCredentialID == nil || *l.UserStorageCredentialID < 1 || l.UserStorageCredential == nil { 71 | writeError(w, "The link is unavailable") 72 | w.WriteHeader(http.StatusServiceUnavailable) 73 | return 74 | } 75 | 76 | // check for password 77 | if l.IsProtected() { 78 | password := r.FormValue("password") 79 | 80 | if !linkSvc.CheckLinkPassword(l, password) { 81 | writeError(w, "Invalid Password") 82 | w.WriteHeader(http.StatusUnprocessableEntity) 83 | return 84 | } 85 | } 86 | 87 | // check for deadline 88 | if l.Deadline != nil && l.Deadline.Before(time.Now()) { 89 | writeError(w, "Link is Expired") 90 | w.WriteHeader(http.StatusForbidden) 91 | return 92 | } 93 | 94 | storageProviderService, err := storageProviderPool.Get(l.UserStorageCredential.ProviderID) 95 | if err != nil { 96 | if debug { 97 | log.Println("get storage provider service: ", err) 98 | } 99 | writeError(w, "Sorry, but the Storage Provider is unavailable at the time") 100 | w.WriteHeader(http.StatusServiceUnavailable) 101 | return 102 | } 103 | 104 | err = storageProviderService.Upload( 105 | domain.StorageProviderCredential{ 106 | UserAccessToken: l.UserStorageCredential.ProviderCredential, 107 | }, 108 | f, 109 | fileHeader.Filename, 110 | l.Slug, 111 | ) 112 | if err != nil { 113 | if debug { 114 | log.Println("file upload: ", err) 115 | } 116 | writeError(w, "Server Error") 117 | w.WriteHeader(http.StatusInternalServerError) 118 | return 119 | } 120 | 121 | json.NewEncoder(w).Encode(map[string]string{ 122 | "message": "File is successfully uploaded", 123 | }) 124 | w.WriteHeader(http.StatusOK) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /infrastructure/auth/jwt.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "github.com/bccfilkom/drophere-go/domain" 12 | jwt "github.com/dgrijalva/jwt-go" 13 | ) 14 | 15 | var ( 16 | // A private key for context that only this package can access. This is important 17 | // to prevent collisions between different context uses 18 | userCtxKey = &contextKey{"user"} 19 | errInvalidToken = errors.New("jwt: invalid token") 20 | ) 21 | 22 | type contextKey struct { 23 | name string 24 | } 25 | 26 | // JWTAuthenticator struct 27 | type JWTAuthenticator struct { 28 | key []byte 29 | duration time.Duration 30 | algo string 31 | userRepo domain.UserRepository 32 | } 33 | 34 | // NewJWT func 35 | func NewJWT(secret string, duration time.Duration, algo string, userRepo domain.UserRepository) *JWTAuthenticator { 36 | return &JWTAuthenticator{ 37 | key: []byte(secret), 38 | duration: duration, 39 | algo: algo, 40 | userRepo: userRepo, 41 | } 42 | } 43 | 44 | // Authenticate func 45 | func (j *JWTAuthenticator) Authenticate(u *domain.User) (*domain.UserCredentials, error) { 46 | expiry := time.Now().Add(j.duration) 47 | token := jwt.NewWithClaims(jwt.GetSigningMethod(j.algo), jwt.MapClaims{ 48 | "user_id": u.ID, 49 | "exp": expiry.Unix(), 50 | }) 51 | 52 | tokenS, err := token.SignedString(j.key) 53 | if err != nil { 54 | return nil, err 55 | } 56 | return &domain.UserCredentials{ 57 | Token: tokenS, 58 | Expiry: &expiry, 59 | }, nil 60 | } 61 | 62 | func (j *JWTAuthenticator) validateAndGetUserID(token string) (uint, error) { 63 | payloadI, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { 64 | if jwt.GetSigningMethod(j.algo) != token.Method { 65 | return nil, errInvalidToken 66 | } 67 | 68 | return j.key, nil 69 | }) 70 | 71 | if err != nil { 72 | return 0, err 73 | } 74 | 75 | if !payloadI.Valid { 76 | return 0, errInvalidToken 77 | } 78 | 79 | claims := payloadI.Claims.(jwt.MapClaims) 80 | 81 | userID, ok := claims["user_id"].(float64) 82 | if !ok { 83 | return 0, errInvalidToken 84 | } 85 | 86 | return uint(userID), nil 87 | } 88 | 89 | func writeGqlError(w http.ResponseWriter, msg string) { 90 | json.NewEncoder(w).Encode(map[string]interface{}{ 91 | "errors": []map[string]string{ 92 | { 93 | "message": msg, 94 | }, 95 | }, 96 | }) 97 | } 98 | 99 | // Middleware func 100 | func (j *JWTAuthenticator) Middleware() func(http.Handler) http.Handler { 101 | return func(next http.Handler) http.Handler { 102 | // cast inner function to HandlerFunc 103 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 104 | authHeader := r.Header.Get("Authorization") 105 | 106 | // Allow unauthenticated users in 107 | if authHeader == "" { 108 | next.ServeHTTP(w, r) 109 | return 110 | } 111 | 112 | spaceIdx := strings.IndexByte(authHeader, ' ') 113 | if spaceIdx < 0 { 114 | writeGqlError(w, "Invalid Authorization header") 115 | return 116 | } 117 | authHeaderPrefix := authHeader[:spaceIdx] 118 | authToken := authHeader[spaceIdx+1:] 119 | 120 | if authHeaderPrefix != "bearer" && authHeaderPrefix != "Bearer" { 121 | writeGqlError(w, "Invalid Authorization header") 122 | return 123 | } 124 | 125 | userID, err := j.validateAndGetUserID(authToken) 126 | if err != nil { 127 | writeGqlError(w, "Invalid or expired token") 128 | return 129 | } 130 | 131 | // get the user from the database 132 | user, err := j.userRepo.FindByID(userID) 133 | if err != nil { 134 | writeGqlError(w, "Server Error") 135 | return 136 | } 137 | 138 | // put it in context 139 | ctx := context.WithValue(r.Context(), userCtxKey, user) 140 | 141 | // and call the next with our new context 142 | r = r.WithContext(ctx) 143 | next.ServeHTTP(w, r) 144 | }) 145 | } 146 | } 147 | 148 | // GetAuthenticatedUser finds the user from the context. REQUIRES Middleware to have run. 149 | func (j *JWTAuthenticator) GetAuthenticatedUser(ctx context.Context) *domain.User { 150 | raw, _ := ctx.Value(userCtxKey).(*domain.User) 151 | return raw 152 | } 153 | -------------------------------------------------------------------------------- /domain/link/link.go: -------------------------------------------------------------------------------- 1 | package link 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/bccfilkom/drophere-go/domain" 7 | ) 8 | 9 | type service struct { 10 | linkRepo domain.LinkRepository 11 | uscRepo domain.UserStorageCredentialRepository 12 | passwordHasher domain.Hasher 13 | } 14 | 15 | // NewService returns new service instance 16 | func NewService( 17 | linkRepo domain.LinkRepository, 18 | uscRepo domain.UserStorageCredentialRepository, 19 | passwordHasher domain.Hasher, 20 | ) domain.LinkService { 21 | return &service{ 22 | linkRepo: linkRepo, 23 | uscRepo: uscRepo, 24 | passwordHasher: passwordHasher, 25 | } 26 | } 27 | 28 | // CheckLinkPassword checks if user-inputted password match the hashed password 29 | func (s *service) CheckLinkPassword(l *domain.Link, password string) bool { 30 | // skip password checking if link is not protected 31 | if !l.IsProtected() { 32 | return true 33 | } 34 | 35 | return s.passwordHasher.Verify(l.Password, password) 36 | } 37 | 38 | // CreateLink creates new Link and store it to repository 39 | func (s *service) CreateLink(title, slug, description string, deadline *time.Time, password *string, user *domain.User, providerID *uint) (*domain.Link, error) { 40 | l, err := s.linkRepo.FindBySlug(slug) 41 | if err != nil && err != domain.ErrLinkNotFound { 42 | return nil, err 43 | } 44 | 45 | if l != nil { 46 | return nil, domain.ErrLinkDuplicatedSlug 47 | } 48 | 49 | l = &domain.Link{ 50 | UserID: user.ID, 51 | Title: title, 52 | Slug: slug, 53 | Description: description, 54 | Deadline: deadline, 55 | } 56 | 57 | if password != nil && *password != "" { 58 | l.Password, err = s.passwordHasher.Hash(*password) 59 | if err != nil { 60 | return nil, err 61 | } 62 | } 63 | 64 | if providerID != nil && *providerID > 0 { 65 | uscs, err := s.uscRepo.Find( 66 | domain.UserStorageCredentialFilters{ 67 | UserIDs: []uint{user.ID}, 68 | ProviderIDs: []uint{*providerID}, 69 | }, 70 | false, 71 | ) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | if len(uscs) < 1 { 77 | return nil, domain.ErrUserStorageCredentialNotFound 78 | } 79 | l.UserStorageCredentialID = &(uscs[0].ID) 80 | l.UserStorageCredential = &uscs[0] 81 | } 82 | 83 | return s.linkRepo.Create(l) 84 | } 85 | 86 | // UpdateLink updates existing Link and save it to repository 87 | func (s *service) UpdateLink(linkID uint, title, slug string, description *string, deadline *time.Time, password *string, providerID *uint) (*domain.Link, error) { 88 | l, err := s.linkRepo.FindByID(linkID) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | // check duplicated slug 94 | link2, err := s.linkRepo.FindBySlug(slug) 95 | if err != nil && err != domain.ErrLinkNotFound { 96 | return nil, err 97 | } 98 | 99 | if link2 != nil && link2.ID != l.ID { 100 | return nil, domain.ErrLinkDuplicatedSlug 101 | } 102 | 103 | l.Title = title 104 | l.Slug = slug 105 | l.Deadline = deadline // set null if the user want to remove the deadline 106 | if description != nil { 107 | l.Description = *description 108 | } 109 | 110 | if password != nil { 111 | if *password == "" { 112 | l.Password = *password 113 | } else { 114 | l.Password, err = s.passwordHasher.Hash(*password) 115 | if err != nil { 116 | return nil, err 117 | } 118 | } 119 | } 120 | 121 | // user can unset the UserStorageProviderID by passing 0 to providerID 122 | if providerID != nil { 123 | if *providerID <= 0 { 124 | l.UserStorageCredentialID = nil 125 | l.UserStorageCredential = nil 126 | } else { 127 | uscs, err := s.uscRepo.Find( 128 | domain.UserStorageCredentialFilters{ 129 | UserIDs: []uint{l.UserID}, 130 | ProviderIDs: []uint{*providerID}, 131 | }, 132 | false, 133 | ) 134 | 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | if len(uscs) < 1 { 140 | return nil, domain.ErrUserStorageCredentialNotFound 141 | } 142 | l.UserStorageCredentialID = &(uscs[0].ID) 143 | l.UserStorageCredential = &uscs[0] 144 | 145 | } 146 | } 147 | 148 | return s.linkRepo.Update(l) 149 | } 150 | 151 | // DeleteLink delete existing Link specified by its ID 152 | func (s *service) DeleteLink(id uint) error { 153 | l, err := s.linkRepo.FindByID(id) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | return s.linkRepo.Delete(l) 159 | } 160 | 161 | // FetchLink returns single Link identified by its ID 162 | func (s *service) FetchLink(id uint) (*domain.Link, error) { 163 | return s.linkRepo.FindByID(id) 164 | } 165 | 166 | // FindLinkBySlug returns single Link identified by its slug 167 | func (s *service) FindLinkBySlug(slug string) (*domain.Link, error) { 168 | return s.linkRepo.FindBySlug(slug) 169 | } 170 | 171 | // ListLinks returns list of Link which belongs to a user 172 | func (s *service) ListLinks(userID uint) ([]domain.Link, error) { 173 | return s.linkRepo.ListByUser(userID) 174 | } 175 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "path/filepath" 8 | "time" 9 | 10 | htmlTemplate "html/template" 11 | textTemplate "text/template" 12 | 13 | drophere_go "github.com/bccfilkom/drophere-go" 14 | "github.com/bccfilkom/drophere-go/domain" 15 | "github.com/bccfilkom/drophere-go/domain/link" 16 | "github.com/bccfilkom/drophere-go/domain/user" 17 | "github.com/bccfilkom/drophere-go/infrastructure/auth" 18 | "github.com/bccfilkom/drophere-go/infrastructure/database/mysql" 19 | "github.com/bccfilkom/drophere-go/infrastructure/hasher" 20 | "github.com/bccfilkom/drophere-go/infrastructure/mailer" 21 | "github.com/bccfilkom/drophere-go/infrastructure/storageprovider" 22 | "github.com/bccfilkom/drophere-go/infrastructure/stringgenerator" 23 | 24 | "github.com/99designs/gqlgen/handler" 25 | "github.com/go-chi/chi" 26 | "github.com/go-chi/chi/middleware" 27 | "github.com/rs/cors" 28 | "github.com/spf13/viper" 29 | 30 | _ "github.com/go-sql-driver/mysql" 31 | ) 32 | 33 | const defaultPort = "8080" 34 | 35 | var debug bool 36 | 37 | func main() { 38 | viper.SetConfigName("config") 39 | viper.SetConfigType("yaml") 40 | viper.AddConfigPath(".") 41 | 42 | err := viper.ReadInConfig() 43 | if err != nil { 44 | panic(fmt.Errorf("config: %s", err)) 45 | } 46 | 47 | viper.SetEnvPrefix("DROPHERE") 48 | viper.AutomaticEnv() 49 | 50 | // set debug mode 51 | debug = viper.GetBool("app.debug") 52 | 53 | port := viper.GetString("PORT") 54 | if port == "" { 55 | port = defaultPort 56 | } 57 | 58 | // setup 59 | db, err := mysql.New(viper.GetString("db.dsn")) 60 | if err != nil { 61 | panic(err) 62 | } 63 | 64 | // initialize repositories 65 | userRepo := mysql.NewUserRepository(db) 66 | linkRepo := mysql.NewLinkRepository(db) 67 | userStorageCredRepo := mysql.NewUserStorageCredentialRepository(db) 68 | 69 | // initialize infrastructures 70 | authenticator := auth.NewJWT( 71 | viper.GetString("jwt.secret"), 72 | time.Duration(viper.GetInt("jwt.duration"))*time.Hour, 73 | viper.GetString("jwt.signingAlgorithm"), 74 | userRepo, 75 | ) 76 | bcryptHasher := hasher.NewBcryptHasher() 77 | // mailtrap := mailer.NewMailtrap( 78 | // viper.GetString("mailer.mailtrap.username"), 79 | // viper.GetString("mailer.mailtrap.password"), 80 | // ) 81 | sendgridMailer := mailer.NewSendgrid( 82 | viper.GetString("mailer.sendgrid.apiKey"), 83 | debug, 84 | ) 85 | uuidGenerator := stringgenerator.NewUUID() 86 | 87 | remoteDirectory := "drophere" 88 | if remoteDirCfg := viper.GetString("app.storageRootDirectoryName"); remoteDirCfg != "" { 89 | remoteDirectory = remoteDirCfg 90 | } 91 | 92 | dropboxService := storageprovider.NewDropboxStorageProvider(remoteDirectory) 93 | storageProviderPool := domain.StorageProviderPool{} 94 | storageProviderPool.Register(dropboxService) 95 | 96 | basePath := viper.GetString("app.templatePath") 97 | htmlTemplates, err := htmlTemplate.ParseGlob(filepath.Join(basePath, "html", "*.html")) 98 | if err != nil { 99 | panic(err) 100 | } 101 | 102 | textTemplates, err := textTemplate.ParseGlob(filepath.Join(basePath, "text", "*.txt")) 103 | if err != nil { 104 | panic(err) 105 | } 106 | 107 | // initialize services 108 | userSvc := user.NewService( 109 | userRepo, 110 | userStorageCredRepo, 111 | authenticator, 112 | sendgridMailer, 113 | bcryptHasher, 114 | uuidGenerator, 115 | storageProviderPool, 116 | htmlTemplates, 117 | textTemplates, 118 | user.Config{ 119 | PasswordRecoveryTokenExpiryDuration: viper.GetInt("app.passwordRecovery.tokenExpiryDuration"), 120 | RecoverPasswordWebURL: viper.GetString("app.passwordRecovery.webURL"), 121 | MailerEmail: viper.GetString("app.passwordRecovery.mailer.email"), 122 | MailerName: viper.GetString("app.passwordRecovery.mailer.name"), 123 | }, 124 | ) 125 | linkSvc := link.NewService(linkRepo, userStorageCredRepo, bcryptHasher) 126 | 127 | resolver := drophere_go.NewResolver(userSvc, authenticator, linkSvc) 128 | 129 | // setup router 130 | router := chi.NewRouter() 131 | 132 | // A good base middleware stack 133 | router.Use(cors.New(cors.Options{ 134 | AllowedOrigins: []string{"*"}, 135 | AllowCredentials: true, 136 | AllowedHeaders: []string{"*"}, 137 | Debug: debug, 138 | }).Handler) 139 | router.Use(authenticator.Middleware()) 140 | router.Use(middleware.RequestID) 141 | router.Use(middleware.RealIP) 142 | router.Use(middleware.Logger) 143 | router.Use(middleware.Recoverer) 144 | 145 | router.Handle("/", handler.Playground("GraphQL playground", "/query")) 146 | router.Handle("/query", handler.GraphQL(drophere_go.NewExecutableSchema(drophere_go.Config{Resolvers: resolver}))) 147 | router.Post("/uploadfile", fileUploadHandler(userSvc, linkSvc, storageProviderPool)) 148 | 149 | log.Printf("connect to http://localhost:%s/ for GraphQL playground", port) 150 | err = http.ListenAndServe(":"+port, router) 151 | if err != nil { 152 | log.Fatal(err) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /infrastructure/storageprovider/dropbox.go: -------------------------------------------------------------------------------- 1 | package storageprovider 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "strings" 11 | "time" 12 | 13 | "github.com/bccfilkom/drophere-go/domain" 14 | ) 15 | 16 | var ( 17 | errNotEnoughScope = errors.New("Not enough scope given from the Dropbox access token. Please grant the required scope 'files.content.write' and reset the access token.") 18 | ) 19 | 20 | const dropboxProviderID uint = 12345678 21 | 22 | type dropbox struct { 23 | remoteDirectory string 24 | } 25 | 26 | type dropboxError struct { 27 | HttpCode int 28 | Message string 29 | Json dropboxErrorJson 30 | } 31 | 32 | type dropboxErrorJson struct { 33 | ErrorSummary string `json:"error_summary"` 34 | ErrorStructured map[string]interface{} `json:"error"` 35 | UserMessage string `json:"user_message"` 36 | } 37 | 38 | // NewDropboxStorageProvider returns new StorageProviderService 39 | func NewDropboxStorageProvider(remoteDirectory string) domain.StorageProviderService { 40 | return &dropbox{ 41 | remoteDirectory: remoteDirectory, 42 | } 43 | } 44 | 45 | // ID returns provider ID 46 | func (d *dropbox) ID() uint { 47 | return dropboxProviderID 48 | } 49 | 50 | // AccountInfo fetches Dropbox account information 51 | func (d *dropbox) AccountInfo(cred domain.StorageProviderCredential) (domain.StorageProviderAccountInfo, error) { 52 | var accountInfo domain.StorageProviderAccountInfo 53 | 54 | req, err := http.NewRequest( 55 | http.MethodPost, 56 | "https://api.dropboxapi.com/2/users/get_current_account", 57 | nil, 58 | ) 59 | if err != nil { 60 | return accountInfo, err 61 | } 62 | 63 | // prepare header (no need to set content-type) 64 | req.Header.Set("Authorization", "Bearer "+cred.UserAccessToken) 65 | 66 | client := http.Client{ 67 | Timeout: 5 * time.Second, 68 | } 69 | 70 | // do http request 71 | resp, err := client.Do(req) 72 | if err != nil { 73 | return accountInfo, err 74 | } 75 | 76 | defer resp.Body.Close() 77 | 78 | // read body 79 | respBodyBytes, err := ioutil.ReadAll(resp.Body) 80 | if err != nil { 81 | return accountInfo, err 82 | } 83 | 84 | var respBody map[string]interface{} 85 | 86 | err = json.Unmarshal(respBodyBytes, &respBody) 87 | if err != nil { 88 | return accountInfo, err 89 | } 90 | 91 | accountInfo.Email, _ = respBody["email"].(string) 92 | accountInfo.Photo, _ = respBody["profile_photo_url"].(string) 93 | 94 | return accountInfo, nil 95 | } 96 | 97 | // Upload sends the file to Dropbox server 98 | func (d *dropbox) Upload(cred domain.StorageProviderCredential, file io.Reader, fileName, slug string) error { 99 | 100 | req, err := d.prepareRequest(cred.UserAccessToken, file, fileName, slug) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | client := http.Client{ 106 | Timeout: 10 * time.Second, 107 | } 108 | 109 | // do the request 110 | res, err := client.Do(req) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | if res.StatusCode != http.StatusOK { 116 | dropboxError, err := d.mapToDropboxError(res.Body, res.StatusCode) 117 | defer res.Body.Close() 118 | if err != nil { 119 | return err 120 | } 121 | 122 | regularError := d.mapToRegularError(dropboxError) 123 | 124 | return regularError 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func (d *dropbox) prepareRequest(accessToken string, file io.Reader, fileName, slug string) (*http.Request, error) { 131 | 132 | req, err := http.NewRequest( 133 | http.MethodPost, 134 | "https://content.dropboxapi.com/2/files/upload", 135 | file, 136 | ) 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | // construct Dropbox API arguments 142 | dropboxAPIArg := fmt.Sprintf( 143 | `{"path": "/%s/%s/%s","mode": "add","autorename": true,"mute": false}`, 144 | d.remoteDirectory, 145 | slug, 146 | fileName, 147 | ) 148 | 149 | // prepare header 150 | req.Header.Set("Authorization", "Bearer "+accessToken) 151 | req.Header.Set("Content-Type", "application/octet-stream") 152 | req.Header.Set("Dropbox-API-Arg", dropboxAPIArg) 153 | return req, nil 154 | } 155 | 156 | func (d *dropbox) mapToDropboxError(responseReader io.Reader, httpStatusCode int) (dropboxError, error) { 157 | byteResponse, err := io.ReadAll(responseReader) 158 | if err != nil { 159 | return dropboxError{}, err 160 | } 161 | errorRes := dropboxError{HttpCode: httpStatusCode} 162 | errorRes.Message = string(byteResponse) 163 | if httpStatusCode == http.StatusUnauthorized { 164 | errorRes.Json = dropboxErrorJson{} 165 | json.Unmarshal(byteResponse, &errorRes.Json) 166 | } 167 | 168 | return errorRes, nil 169 | } 170 | 171 | func (d *dropbox) mapToRegularError(dropboxError dropboxError) error { 172 | if dropboxError.HttpCode == http.StatusBadRequest { 173 | if strings.Contains(dropboxError.Message, "files.content.write") { 174 | return errNotEnoughScope 175 | } 176 | } else if dropboxError.HttpCode == http.StatusUnauthorized { 177 | if dropboxError.Json.ErrorStructured[".tag"].(string) == "missing_scope" && dropboxError.Json.ErrorStructured["required_scope"].(string) == "files.content.write" { 178 | return errNotEnoughScope 179 | } 180 | } 181 | 182 | return errors.New("Unknown dropbox error.\n" + dropboxError.Message) 183 | } 184 | -------------------------------------------------------------------------------- /domain/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "time" 7 | 8 | htmlTemplate "html/template" 9 | textTemplate "text/template" 10 | 11 | "github.com/bccfilkom/drophere-go/domain" 12 | ) 13 | 14 | const defaultTokenExpiryDuration int = 5 15 | 16 | // Config model 17 | type Config struct { 18 | PasswordRecoveryTokenExpiryDuration int 19 | RecoverPasswordWebURL string 20 | MailerEmail string 21 | MailerName string 22 | } 23 | 24 | type service struct { 25 | userRepo domain.UserRepository 26 | userStorageCredRepo domain.UserStorageCredentialRepository 27 | authenticator domain.Authenticator 28 | mailer domain.Mailer 29 | passwordHasher domain.Hasher 30 | stringGenerator domain.StringGenerator 31 | 32 | storageProviderPool domain.StorageProviderPool 33 | 34 | htmlTemplates *htmlTemplate.Template 35 | textTemplates *textTemplate.Template 36 | 37 | config Config 38 | } 39 | 40 | // NewService returns service instance 41 | func NewService( 42 | userRepo domain.UserRepository, 43 | userStorageCredRepo domain.UserStorageCredentialRepository, 44 | authenticator domain.Authenticator, 45 | mailer domain.Mailer, 46 | passwordHasher domain.Hasher, 47 | stringGenerator domain.StringGenerator, 48 | storageProviderPool domain.StorageProviderPool, 49 | htmlTemplates *htmlTemplate.Template, 50 | textTemplates *textTemplate.Template, 51 | config Config, 52 | ) domain.UserService { 53 | return &service{ 54 | userRepo: userRepo, 55 | userStorageCredRepo: userStorageCredRepo, 56 | authenticator: authenticator, 57 | mailer: mailer, 58 | passwordHasher: passwordHasher, 59 | stringGenerator: stringGenerator, 60 | 61 | storageProviderPool: storageProviderPool, 62 | 63 | htmlTemplates: htmlTemplates, 64 | textTemplates: textTemplates, 65 | 66 | config: config, 67 | } 68 | } 69 | 70 | // Register implementation 71 | func (s *service) Register(email, name, password string) (*domain.User, error) { 72 | // check for existing email prior to creating new user 73 | user, err := s.userRepo.FindByEmail(email) 74 | if err != nil && err != domain.ErrUserNotFound { 75 | return nil, err 76 | } 77 | 78 | if user != nil { 79 | return nil, domain.ErrUserDuplicated 80 | } 81 | 82 | user = &domain.User{ 83 | Email: email, 84 | Name: name, 85 | } 86 | 87 | user.Password, err = s.passwordHasher.Hash(password) 88 | if err != nil { 89 | return nil, err 90 | } 91 | return s.userRepo.Create(user) 92 | } 93 | 94 | // Auth implementation 95 | func (s *service) Auth(email, password string) (*domain.UserCredentials, error) { 96 | user, err := s.userRepo.FindByEmail(email) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | if !s.passwordHasher.Verify(user.Password, password) { 102 | return nil, domain.ErrUserInvalidPassword 103 | } 104 | 105 | return s.authenticator.Authenticate(user) 106 | } 107 | 108 | // Update implementation 109 | func (s *service) Update(userID uint, name, newPassword, oldPassword *string) (*domain.User, error) { 110 | u, err := s.userRepo.FindByID(userID) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | if newPassword != nil { 116 | if oldPassword == nil || !s.passwordHasher.Verify(u.Password, *oldPassword) { 117 | return nil, domain.ErrUserInvalidPassword 118 | } 119 | 120 | u.Password, err = s.passwordHasher.Hash(*newPassword) 121 | if err != nil { 122 | return nil, err 123 | } 124 | } 125 | 126 | if name != nil { 127 | u.Name = *name 128 | } 129 | 130 | return s.userRepo.Update(u) 131 | } 132 | 133 | // UpdateStorageToken implementation 134 | func (s *service) UpdateStorageToken(userID uint, dropboxToken *string) (*domain.User, error) { 135 | u, err := s.userRepo.FindByID(userID) 136 | if err != nil { 137 | return nil, err 138 | } 139 | 140 | u.DropboxToken = dropboxToken 141 | 142 | return s.userRepo.Update(u) 143 | } 144 | 145 | // RequestPasswordRecovery implementation 146 | func (s *service) RequestPasswordRecovery(email string) error { 147 | u, err := s.userRepo.FindByEmail(email) 148 | if err != nil { 149 | return err 150 | } 151 | 152 | // TODO: check if user has already requested password recovery to avoid spam 153 | tokenExpiryDuration := defaultTokenExpiryDuration 154 | if s.config.PasswordRecoveryTokenExpiryDuration > 0 { 155 | tokenExpiryDuration = s.config.PasswordRecoveryTokenExpiryDuration 156 | } 157 | 158 | token := s.stringGenerator.Generate() 159 | tokenExpiry := time.Now().Add(time.Minute * time.Duration(tokenExpiryDuration)) 160 | u.RecoverPasswordToken = &token 161 | u.RecoverPasswordTokenExpiry = &tokenExpiry 162 | 163 | // save the user 164 | u, err = s.userRepo.Update(u) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | // send email 170 | err = s.sendPasswordRecoveryTokenToEmail( 171 | domain.MailAddress{ 172 | Address: u.Email, 173 | Name: u.Name, 174 | }, 175 | "Recover Password", 176 | u.Email, 177 | token, 178 | ) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | return nil 184 | } 185 | 186 | func (s *service) sendPasswordRecoveryTokenToEmail(to domain.MailAddress, subject, email, token string) error { 187 | 188 | // preparing template 189 | htmlTmpl := s.htmlTemplates.Lookup("request_password_recovery_html") 190 | if htmlTmpl == nil { 191 | return domain.ErrTemplateNotFound 192 | } 193 | 194 | textTmpl := s.textTemplates.Lookup("request_password_recovery_text") 195 | if textTmpl == nil { 196 | return domain.ErrTemplateNotFound 197 | } 198 | 199 | // preparing template content 200 | messageData := map[string]string{ 201 | "ResetPasswordLink": fmt.Sprintf( 202 | "%s?token=%s&email=%s", 203 | s.config.RecoverPasswordWebURL, 204 | token, 205 | email, 206 | ), 207 | "Token": token, 208 | } 209 | 210 | // injecting data to template 211 | htmlMessage := &bytes.Buffer{} 212 | htmlTmpl.Execute(htmlMessage, messageData) 213 | 214 | textMessage := &bytes.Buffer{} 215 | textTmpl.Execute(textMessage, messageData) 216 | 217 | from := domain.MailAddress{ 218 | Address: "admin@drophere.link", 219 | Name: "Drophere Bot", 220 | } 221 | 222 | if s.config.MailerEmail != "" { 223 | from.Address = s.config.MailerEmail 224 | } 225 | 226 | if s.config.MailerName != "" { 227 | from.Name = s.config.MailerName 228 | } 229 | 230 | // send email 231 | return s.mailer.Send( 232 | from, 233 | to, 234 | subject, 235 | textMessage.String(), 236 | htmlMessage.String(), 237 | ) 238 | } 239 | 240 | func (s *service) RecoverPassword(email, token, newPassword string) error { 241 | u, err := s.userRepo.FindByEmail(email) 242 | if err != nil { 243 | return err 244 | } 245 | 246 | if token == "" || u.RecoverPasswordToken == nil || *u.RecoverPasswordToken != token { 247 | return domain.ErrUserNotFound 248 | } 249 | 250 | if u.RecoverPasswordTokenExpiry == nil || time.Now().After(*u.RecoverPasswordTokenExpiry) { 251 | return domain.ErrUserPasswordRecoveryTokenExpired 252 | } 253 | 254 | u.Password, err = s.passwordHasher.Hash(newPassword) 255 | if err != nil { 256 | return err 257 | } 258 | 259 | u.RecoverPasswordToken, u.RecoverPasswordTokenExpiry = nil, nil 260 | 261 | u, err = s.userRepo.Update(u) 262 | if err != nil { 263 | return err 264 | } 265 | 266 | return nil 267 | } 268 | -------------------------------------------------------------------------------- /resolver.go: -------------------------------------------------------------------------------- 1 | package drophere_go 2 | 3 | //go:generate go run github.com/99designs/gqlgen 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "time" 9 | 10 | "github.com/bccfilkom/drophere-go/domain" 11 | ) // THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES. 12 | 13 | var ( 14 | errUnauthenticated = errors.New("Access denied") 15 | errUnauthorized = errors.New("You are not allowed to do this operation") 16 | ) 17 | 18 | type authenticator interface { 19 | GetAuthenticatedUser(context.Context) *domain.User 20 | } 21 | 22 | // Resolver resolves given query from client 23 | type Resolver struct { 24 | linkSvc domain.LinkService 25 | userSvc domain.UserService 26 | authenticator authenticator 27 | } 28 | 29 | // NewResolver func 30 | func NewResolver( 31 | userSvc domain.UserService, 32 | authenticator authenticator, 33 | linkSvc domain.LinkService, 34 | ) *Resolver { 35 | return &Resolver{ 36 | linkSvc: linkSvc, 37 | userSvc: userSvc, 38 | authenticator: authenticator, 39 | } 40 | } 41 | 42 | // Mutation returns a group of resolvers for mutation query 43 | func (r *Resolver) Mutation() MutationResolver { 44 | return &mutationResolver{r} 45 | } 46 | 47 | // Query returns a group of resolvers for query 48 | func (r *Resolver) Query() QueryResolver { 49 | return &queryResolver{r} 50 | } 51 | 52 | type mutationResolver struct{ *Resolver } 53 | 54 | // Register resolver 55 | func (r *mutationResolver) Register(ctx context.Context, email string, password string, name string) (*Token, error) { 56 | user, err := r.userSvc.Register(email, name, password) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | userCreds, err := r.userSvc.Auth(user.Email, password) 62 | if err != nil { 63 | return nil, err 64 | } 65 | return &Token{LoginToken: userCreds.Token}, nil 66 | } 67 | 68 | // Login resolver 69 | func (r *mutationResolver) Login(ctx context.Context, email string, password string) (*Token, error) { 70 | userCreds, err := r.userSvc.Auth(email, password) 71 | if err != nil { 72 | return nil, err 73 | } 74 | return &Token{LoginToken: userCreds.Token}, nil 75 | } 76 | 77 | // RequestPasswordRecovery resolver 78 | func (r *mutationResolver) RequestPasswordRecovery(ctx context.Context, email string) (*Message, error) { 79 | err := r.userSvc.RequestPasswordRecovery(email) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | return &Message{"Recover Password instruction has been sent to your email"}, nil 85 | } 86 | 87 | // RecoverPassword resolver 88 | func (r *mutationResolver) RecoverPassword(ctx context.Context, email, recoverToken, newPassword string) (*Token, error) { 89 | err := r.userSvc.RecoverPassword(email, recoverToken, newPassword) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | userCreds, err := r.userSvc.Auth(email, newPassword) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | return &Token{LoginToken: userCreds.Token}, nil 100 | } 101 | 102 | // UpdatePassword resolver 103 | func (r *mutationResolver) UpdatePassword(ctx context.Context, oldPassword string, newPassword string) (*Message, error) { 104 | user := r.authenticator.GetAuthenticatedUser(ctx) 105 | if user == nil { 106 | return nil, errUnauthenticated 107 | } 108 | 109 | _, err := r.userSvc.Update(user.ID, nil, &newPassword, &oldPassword) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | return &Message{Message: "You password successfully updated"}, nil 115 | } 116 | 117 | // UpdateProfile resolver 118 | func (r *mutationResolver) UpdateProfile(ctx context.Context, newName string) (*Message, error) { 119 | user := r.authenticator.GetAuthenticatedUser(ctx) 120 | if user == nil { 121 | return nil, errUnauthenticated 122 | } 123 | 124 | _, err := r.userSvc.Update(user.ID, &newName, nil, nil) 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | return &Message{Message: "Your profile successfully updated"}, nil 130 | } 131 | 132 | // CreateLink resolver 133 | func (r *mutationResolver) CreateLink(ctx context.Context, title string, slug string, description *string, deadline *time.Time, password *string, providerID *int) (*Link, error) { 134 | user := r.authenticator.GetAuthenticatedUser(ctx) 135 | if user == nil { 136 | return nil, errUnauthenticated 137 | } 138 | 139 | desc := "" 140 | if description != nil { 141 | desc = *description 142 | } 143 | 144 | var providerIDUintPtr *uint 145 | if providerID != nil { 146 | providerIDUint := uint(*providerID) 147 | providerIDUintPtr = &providerIDUint 148 | } 149 | 150 | l, err := r.linkSvc.CreateLink(title, slug, desc, deadline, password, user, providerIDUintPtr) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | return formatLink(*l), nil 156 | } 157 | 158 | // UpdateLink resolver 159 | func (r *mutationResolver) UpdateLink(ctx context.Context, linkID int, title string, slug string, description *string, deadline *time.Time, password *string, providerID *int) (*Link, error) { 160 | user := r.authenticator.GetAuthenticatedUser(ctx) 161 | if user == nil { 162 | return nil, errUnauthenticated 163 | } 164 | 165 | l, err := r.linkSvc.FetchLink(uint(linkID)) 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | if l.UserID != user.ID { 171 | return nil, errUnauthorized 172 | } 173 | 174 | var providerIDUintPtr *uint 175 | if providerID != nil { 176 | providerIDUint := uint(*providerID) 177 | providerIDUintPtr = &providerIDUint 178 | } 179 | 180 | l, err = r.linkSvc.UpdateLink( 181 | uint(linkID), 182 | title, 183 | slug, 184 | description, 185 | deadline, 186 | password, 187 | providerIDUintPtr, 188 | ) 189 | 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | return formatLink(*l), nil 195 | } 196 | 197 | // DeleteLink resolver 198 | func (r *mutationResolver) DeleteLink(ctx context.Context, linkID int) (*Message, error) { 199 | user := r.authenticator.GetAuthenticatedUser(ctx) 200 | if user == nil { 201 | return nil, errUnauthenticated 202 | } 203 | 204 | l, err := r.linkSvc.FetchLink(uint(linkID)) 205 | if err != nil { 206 | return nil, err 207 | } 208 | 209 | if l.UserID != user.ID { 210 | return nil, errUnauthorized 211 | } 212 | 213 | err = r.linkSvc.DeleteLink(uint(linkID)) 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | return &Message{Message: "Link Deleted!"}, nil 219 | } 220 | 221 | // CheckLinkPassword resolver 222 | func (r *mutationResolver) CheckLinkPassword(ctx context.Context, linkID int, password string) (*Message, error) { 223 | // this is for public use, no need to check user auth 224 | l, err := r.linkSvc.FetchLink(uint(linkID)) 225 | if err != nil { 226 | return nil, err 227 | } 228 | 229 | msg := "Invalid Password" 230 | if r.linkSvc.CheckLinkPassword(l, password) { 231 | msg = "Valid Password" 232 | } 233 | 234 | return &Message{Message: msg}, nil 235 | } 236 | 237 | // ConnectStorageProvider resolver 238 | func (r *mutationResolver) ConnectStorageProvider(ctx context.Context, providerID int, providerToken string) (*Message, error) { 239 | user := r.authenticator.GetAuthenticatedUser(ctx) 240 | if user == nil { 241 | return nil, errUnauthenticated 242 | } 243 | 244 | err := r.userSvc.ConnectStorageProvider(user.ID, uint(providerID), providerToken) 245 | if err != nil { 246 | return nil, err 247 | } 248 | 249 | return &Message{Message: "Storage Provider successfully connected"}, nil 250 | } 251 | 252 | // DisconnectStorageProvider resolver 253 | func (r *mutationResolver) DisconnectStorageProvider(ctx context.Context, providerID int) (*Message, error) { 254 | user := r.authenticator.GetAuthenticatedUser(ctx) 255 | if user == nil { 256 | return nil, errUnauthenticated 257 | } 258 | 259 | err := r.userSvc.DisconnectStorageProvider(user.ID, uint(providerID)) 260 | if err != nil { 261 | return nil, err 262 | } 263 | 264 | return &Message{Message: "Storage Provider disconnected"}, nil 265 | } 266 | 267 | type queryResolver struct{ *Resolver } 268 | 269 | // Links resolver 270 | func (r *queryResolver) Links(ctx context.Context) ([]*Link, error) { 271 | user := r.authenticator.GetAuthenticatedUser(ctx) 272 | if user == nil { 273 | return nil, errUnauthenticated 274 | } 275 | 276 | links, err := r.linkSvc.ListLinks(user.ID) 277 | if err != nil { 278 | return nil, err 279 | } 280 | 281 | return formatLinks(links), nil 282 | } 283 | 284 | // Me resolver 285 | func (r *queryResolver) Me(ctx context.Context) (*User, error) { 286 | user := r.authenticator.GetAuthenticatedUser(ctx) 287 | if user == nil { 288 | return nil, errUnauthenticated 289 | } 290 | 291 | uscs, err := r.userSvc.ListStorageProviders(user.ID) 292 | if err != nil { 293 | return nil, err 294 | } 295 | 296 | // map from domain.UserStorageProviderCredential to StorageProvider 297 | storageProviders := make([]*StorageProvider, len(uscs)) 298 | for i, usc := range uscs { 299 | storageProviders[i] = &StorageProvider{ 300 | ID: int(usc.ID), 301 | ProviderID: int(usc.ProviderID), 302 | Email: usc.Email, 303 | Photo: usc.Photo, 304 | } 305 | } 306 | 307 | return &User{ 308 | ID: int(user.ID), 309 | Email: user.Email, 310 | Name: user.Name, 311 | ConnectedStorageProviders: storageProviders, 312 | }, nil 313 | } 314 | 315 | // Link resolver 316 | func (r *queryResolver) Link(ctx context.Context, slug string) (*Link, error) { 317 | // this is for public use, no need to check user auth 318 | link, err := r.linkSvc.FindLinkBySlug(slug) 319 | if err != nil { 320 | return nil, err 321 | } 322 | 323 | return formatLink(*link), nil 324 | } 325 | 326 | func formatLink(link domain.Link) *Link { 327 | formattedLink := &Link{ 328 | ID: int(link.ID), 329 | Title: link.Title, 330 | IsProtected: link.IsProtected(), 331 | Slug: &link.Slug, 332 | Description: &link.Description, 333 | Deadline: link.Deadline, 334 | } 335 | 336 | if link.UserStorageCredential != nil { 337 | formattedLink.StorageProvider = &StorageProvider{ 338 | ID: int(link.UserStorageCredential.ID), 339 | ProviderID: int(link.UserStorageCredential.ProviderID), 340 | Email: link.UserStorageCredential.Email, 341 | Photo: link.UserStorageCredential.Photo, 342 | } 343 | } 344 | 345 | return formattedLink 346 | } 347 | 348 | func formatLinks(links []domain.Link) []*Link { 349 | formattedLinks := make([]*Link, len(links)) 350 | for i, link := range links { 351 | formattedLinks[i] = &Link{ 352 | ID: int(link.ID), 353 | Title: link.Title, 354 | IsProtected: link.IsProtected(), 355 | Slug: &links[i].Slug, 356 | Description: &links[i].Description, 357 | Deadline: link.Deadline, 358 | } 359 | 360 | if link.UserStorageCredential != nil { 361 | formattedLinks[i].StorageProvider = &StorageProvider{ 362 | ID: int(link.UserStorageCredential.ID), 363 | ProviderID: int(link.UserStorageCredential.ProviderID), 364 | Email: link.UserStorageCredential.Email, 365 | Photo: link.UserStorageCredential.Photo, 366 | } 367 | } 368 | } 369 | return formattedLinks 370 | } 371 | -------------------------------------------------------------------------------- /domain/link/link_test.go: -------------------------------------------------------------------------------- 1 | package link_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | 8 | "github.com/bccfilkom/drophere-go/domain" 9 | "github.com/bccfilkom/drophere-go/domain/link" 10 | "github.com/bccfilkom/drophere-go/infrastructure/database/inmemory" 11 | "github.com/bccfilkom/drophere-go/infrastructure/hasher" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | var dummyHasher domain.Hasher 17 | 18 | func init() { 19 | dummyHasher = hasher.NewNotAHasher() 20 | } 21 | 22 | func newRepo() (domain.LinkRepository, domain.UserRepository, domain.UserStorageCredentialRepository) { 23 | memdb := inmemory.New() 24 | return inmemory.NewLinkRepository(memdb), inmemory.NewUserRepository(memdb), inmemory.NewUserStorageCredentialRepository(memdb) 25 | } 26 | 27 | func str2ptr(s string) *string { 28 | return &s 29 | } 30 | 31 | func time2ptr(t time.Time) *time.Time { 32 | return &t 33 | } 34 | 35 | func uint2ptr(u uint) *uint { 36 | return &u 37 | } 38 | 39 | func TestCheckLinkPassword(t *testing.T) { 40 | type test struct { 41 | link *domain.Link 42 | password string 43 | wantResult bool 44 | } 45 | 46 | linkRepo, _, uscRepo := newRepo() 47 | getLink := func(id uint) *domain.Link { 48 | l, _ := linkRepo.FindByID(id) 49 | return l 50 | } 51 | tests := []test{ 52 | { 53 | link: getLink(1), 54 | password: "", 55 | wantResult: false, 56 | }, 57 | { 58 | link: getLink(1), 59 | password: "abcdef", 60 | wantResult: false, 61 | }, 62 | { 63 | link: getLink(1), 64 | password: "123098", 65 | wantResult: true, 66 | }, 67 | { 68 | link: getLink(2), 69 | password: "123098", 70 | wantResult: true, 71 | }, 72 | { 73 | link: getLink(2), 74 | password: "", 75 | wantResult: true, 76 | }, 77 | } 78 | 79 | linkSvc := link.NewService(linkRepo, uscRepo, dummyHasher) 80 | 81 | for i, tc := range tests { 82 | gotResult := linkSvc.CheckLinkPassword(tc.link, tc.password) 83 | if gotResult != tc.wantResult { 84 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantResult, gotResult) 85 | } 86 | } 87 | 88 | } 89 | 90 | func TestCreateLink(t *testing.T) { 91 | type test struct { 92 | title string 93 | slug string 94 | description string 95 | deadline *time.Time 96 | password *string 97 | user *domain.User 98 | providerID *uint 99 | wantLink *domain.Link 100 | wantErr error 101 | } 102 | 103 | linkRepo, userRepo, uscRepo := newRepo() 104 | user, _ := userRepo.FindByID(1) 105 | uscUser1, _ := uscRepo.FindByID(2000, false) 106 | 107 | linkDeadline := time.Date(2020, time.November, 11, 1, 2, 3, 0, time.UTC) 108 | 109 | tests := []test{ 110 | { 111 | title: "Drop file here", 112 | slug: "drop-here", 113 | description: "drop a file here", 114 | user: user, 115 | wantErr: domain.ErrLinkDuplicatedSlug, 116 | }, 117 | { 118 | title: "Drop CV", 119 | slug: "yoursummerintern", 120 | description: "Drop your CV for summer internship", 121 | user: user, 122 | wantLink: &domain.Link{ 123 | ID: 4, 124 | UserID: user.ID, 125 | Title: "Drop CV", 126 | Slug: "yoursummerintern", 127 | Description: "Drop your CV for summer internship", 128 | }, 129 | wantErr: nil, 130 | }, 131 | { 132 | title: "Link with associated storage provider", 133 | slug: "linktomockbox", 134 | description: "hello there, please upload a file", 135 | user: user, 136 | providerID: uint2ptr(1234), 137 | wantErr: domain.ErrUserStorageCredentialNotFound, 138 | }, 139 | { 140 | title: "Link with associated storage provider", 141 | slug: "linktomockbox", 142 | description: "hello there, please upload a file", 143 | user: user, 144 | providerID: uint2ptr(1), 145 | wantLink: &domain.Link{ 146 | ID: 5, 147 | UserID: user.ID, 148 | Title: "Link with associated storage provider", 149 | Slug: "linktomockbox", 150 | Description: "hello there, please upload a file", 151 | Password: "", 152 | UserStorageCredentialID: uint2ptr(2000), 153 | UserStorageCredential: &uscUser1, 154 | }, 155 | wantErr: nil, 156 | }, 157 | { 158 | title: "Link with associated storage provider", 159 | slug: "guarded-with-pwd-and-deadline", 160 | description: "hello there, please upload a file", 161 | deadline: &linkDeadline, 162 | password: str2ptr("abcdef"), 163 | user: user, 164 | providerID: uint2ptr(1), 165 | wantLink: &domain.Link{ 166 | ID: 6, 167 | UserID: user.ID, 168 | Title: "Link with associated storage provider", 169 | Slug: "guarded-with-pwd-and-deadline", 170 | Description: "hello there, please upload a file", 171 | Deadline: &linkDeadline, 172 | Password: "abcdef", 173 | UserStorageCredentialID: uint2ptr(2000), 174 | UserStorageCredential: &uscUser1, 175 | }, 176 | wantErr: nil, 177 | }, 178 | } 179 | 180 | linkSvc := link.NewService(linkRepo, uscRepo, dummyHasher) 181 | 182 | for _, tc := range tests { 183 | gotLink, gotErr := linkSvc.CreateLink(tc.title, tc.slug, tc.description, tc.deadline, tc.password, tc.user, tc.providerID) 184 | 185 | assert.Equal(t, tc.wantErr, gotErr) 186 | assert.Equal(t, tc.wantLink, gotLink) 187 | } 188 | 189 | } 190 | 191 | func TestUpdateLink(t *testing.T) { 192 | type test struct { 193 | linkID uint 194 | title string 195 | slug string 196 | description *string 197 | deadline *time.Time 198 | password *string 199 | providerID *uint 200 | wantLink *domain.Link 201 | wantErr error 202 | } 203 | 204 | linkRepo, userRepo, uscRepo := newRepo() 205 | user, _ := userRepo.FindByID(1) 206 | uscUser1, _ := uscRepo.FindByID(2000, false) 207 | 208 | tests := []test{ 209 | { 210 | linkID: 123, 211 | title: "Drop file here", 212 | slug: "drop-here", 213 | wantErr: domain.ErrLinkNotFound, 214 | }, 215 | { 216 | linkID: 2, 217 | title: "Drop file here", 218 | slug: "drop-here", 219 | wantErr: domain.ErrLinkDuplicatedSlug, 220 | }, 221 | { 222 | linkID: 1, 223 | title: "Drop CV 2", 224 | slug: "yoursummerintern2", 225 | description: str2ptr("Drop your CV for summer internship 2019"), 226 | deadline: time2ptr(time.Date(2019, 1, 2, 3, 0, 0, 0, time.Local)), 227 | password: str2ptr("123098"), 228 | wantErr: nil, 229 | wantLink: &domain.Link{ 230 | ID: 1, 231 | Title: "Drop CV 2", 232 | Slug: "yoursummerintern2", 233 | Description: "Drop your CV for summer internship 2019", 234 | Deadline: time2ptr(time.Date(2019, 1, 2, 3, 0, 0, 0, time.Local)), 235 | Password: "123098", 236 | UserID: user.ID, 237 | User: user, 238 | }, 239 | }, 240 | { 241 | linkID: 1, 242 | title: "Drop CV 2", 243 | slug: "yoursummerintern2", 244 | description: str2ptr("Drop your CV for summer internship 2019"), 245 | deadline: time2ptr(time.Date(2019, 1, 2, 3, 0, 0, 0, time.Local)), 246 | password: str2ptr("123098"), 247 | providerID: uint2ptr(1234), 248 | wantErr: domain.ErrUserStorageCredentialNotFound, 249 | }, 250 | { 251 | linkID: 1, 252 | title: "Drop CV 2 With MockBox", 253 | slug: "yoursummerintern2mockbox", 254 | description: str2ptr("Drop your CV for summer internship 2019"), 255 | deadline: time2ptr(time.Date(2019, 1, 2, 3, 0, 0, 0, time.Local)), 256 | password: str2ptr("123098"), 257 | providerID: uint2ptr(1), 258 | wantErr: nil, 259 | wantLink: &domain.Link{ 260 | ID: 1, 261 | Title: "Drop CV 2 With MockBox", 262 | Slug: "yoursummerintern2mockbox", 263 | Description: "Drop your CV for summer internship 2019", 264 | Deadline: time2ptr(time.Date(2019, 1, 2, 3, 0, 0, 0, time.Local)), 265 | Password: "123098", 266 | UserID: user.ID, 267 | User: user, 268 | UserStorageCredentialID: uint2ptr(uscUser1.ID), 269 | UserStorageCredential: &uscUser1, 270 | }, 271 | }, 272 | } 273 | 274 | linkSvc := link.NewService(linkRepo, uscRepo, dummyHasher) 275 | 276 | for _, tc := range tests { 277 | gotLink, gotErr := linkSvc.UpdateLink(tc.linkID, tc.title, tc.slug, tc.description, tc.deadline, tc.password, tc.providerID) 278 | 279 | assert.Equal(t, tc.wantErr, gotErr) 280 | assert.Equal(t, tc.wantLink, gotLink) 281 | } 282 | 283 | } 284 | 285 | func TestDeleteLink(t *testing.T) { 286 | type test struct { 287 | linkID uint 288 | wantErr error 289 | } 290 | 291 | linkRepo, _, uscRepo := newRepo() 292 | 293 | tests := []test{ 294 | { 295 | linkID: 123, 296 | wantErr: domain.ErrLinkNotFound, 297 | }, 298 | { 299 | linkID: 1, 300 | wantErr: nil, 301 | }, 302 | } 303 | 304 | linkSvc := link.NewService(linkRepo, uscRepo, dummyHasher) 305 | 306 | for i, tc := range tests { 307 | gotErr := linkSvc.DeleteLink(tc.linkID) 308 | if gotErr != tc.wantErr { 309 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantErr, gotErr) 310 | } 311 | } 312 | 313 | } 314 | 315 | func TestFetchLink(t *testing.T) { 316 | type test struct { 317 | linkID uint 318 | wantErr error 319 | wantLink *domain.Link 320 | } 321 | 322 | linkRepo, userRepo, uscRepo := newRepo() 323 | 324 | user, _ := userRepo.FindByID(1) 325 | 326 | tests := []test{ 327 | { 328 | linkID: 123, 329 | wantErr: domain.ErrLinkNotFound, 330 | }, 331 | { 332 | linkID: 1, 333 | wantErr: nil, 334 | wantLink: &domain.Link{ 335 | ID: 1, 336 | UserID: user.ID, 337 | User: user, 338 | Title: "Drop file here", 339 | Slug: "drop-here", 340 | Password: "123098", 341 | Description: "drop a file here", 342 | }, 343 | }, 344 | } 345 | 346 | linkSvc := link.NewService(linkRepo, uscRepo, dummyHasher) 347 | 348 | for i, tc := range tests { 349 | gotLink, gotErr := linkSvc.FetchLink(tc.linkID) 350 | if gotErr != tc.wantErr { 351 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantErr, gotErr) 352 | } 353 | 354 | if !reflect.DeepEqual(gotLink, tc.wantLink) { 355 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantLink, gotLink) 356 | } 357 | } 358 | 359 | } 360 | 361 | func TestFindLinkBySlug(t *testing.T) { 362 | type test struct { 363 | slug string 364 | wantErr error 365 | wantLink *domain.Link 366 | } 367 | 368 | linkRepo, userRepo, uscRepo := newRepo() 369 | 370 | user, _ := userRepo.FindByID(1) 371 | 372 | tests := []test{ 373 | { 374 | slug: "123", 375 | wantErr: domain.ErrLinkNotFound, 376 | }, 377 | { 378 | slug: "drop-here", 379 | wantErr: nil, 380 | wantLink: &domain.Link{ 381 | ID: 1, 382 | UserID: user.ID, 383 | User: user, 384 | Title: "Drop file here", 385 | Slug: "drop-here", 386 | Password: "123098", 387 | Description: "drop a file here", 388 | }, 389 | }, 390 | } 391 | 392 | linkSvc := link.NewService(linkRepo, uscRepo, dummyHasher) 393 | 394 | for i, tc := range tests { 395 | gotLink, gotErr := linkSvc.FindLinkBySlug(tc.slug) 396 | if gotErr != tc.wantErr { 397 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantErr, gotErr) 398 | } 399 | 400 | if !reflect.DeepEqual(gotLink, tc.wantLink) { 401 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantLink, gotLink) 402 | } 403 | } 404 | 405 | } 406 | 407 | func TestListLinks(t *testing.T) { 408 | type test struct { 409 | userID uint 410 | wantErr error 411 | wantLinks []domain.Link 412 | } 413 | 414 | linkRepo, userRepo, uscRepo := newRepo() 415 | 416 | user, _ := userRepo.FindByID(1) 417 | 418 | tests := []test{ 419 | { 420 | userID: 123, 421 | wantErr: nil, 422 | wantLinks: []domain.Link{}, 423 | }, 424 | { 425 | userID: 1, 426 | wantErr: nil, 427 | wantLinks: []domain.Link{ 428 | { 429 | ID: 1, 430 | UserID: user.ID, 431 | User: user, 432 | Title: "Drop file here", 433 | Slug: "drop-here", 434 | Password: "123098", 435 | Description: "drop a file here", 436 | }, 437 | { 438 | ID: 2, 439 | UserID: user.ID, 440 | User: user, 441 | Title: "Test Link 2", 442 | Slug: "test-link-2", 443 | Password: "", 444 | Description: "no description", 445 | }, 446 | }, 447 | }, 448 | } 449 | 450 | linkSvc := link.NewService(linkRepo, uscRepo, dummyHasher) 451 | 452 | for _, tc := range tests { 453 | gotLinks, gotErr := linkSvc.ListLinks(tc.userID) 454 | 455 | assert.Equal(t, tc.wantErr, gotErr) 456 | assert.Equal(t, tc.wantLinks, gotLinks) 457 | 458 | } 459 | 460 | } 461 | -------------------------------------------------------------------------------- /domain/user/user_test.go: -------------------------------------------------------------------------------- 1 | package user_test 2 | 3 | import ( 4 | "bytes" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | htmlTemplate "html/template" 12 | textTemplate "text/template" 13 | 14 | "github.com/bccfilkom/drophere-go/domain" 15 | "github.com/bccfilkom/drophere-go/domain/user" 16 | "github.com/bccfilkom/drophere-go/infrastructure/auth" 17 | "github.com/bccfilkom/drophere-go/infrastructure/database/inmemory" 18 | "github.com/bccfilkom/drophere-go/infrastructure/hasher" 19 | "github.com/bccfilkom/drophere-go/infrastructure/mailer" 20 | "github.com/bccfilkom/drophere-go/infrastructure/storageprovider" 21 | "github.com/bccfilkom/drophere-go/infrastructure/stringgenerator" 22 | ) 23 | 24 | var ( 25 | authenticator domain.Authenticator 26 | dummyHasher domain.Hasher 27 | mockMailer domain.Mailer 28 | strGen domain.StringGenerator 29 | htmlTemplates *htmlTemplate.Template 30 | textTemplates *textTemplate.Template 31 | 32 | storageProviderPool domain.StorageProviderPool 33 | ) 34 | 35 | func init() { 36 | authenticator = auth.NewJWTMock() 37 | dummyHasher = hasher.NewNotAHasher() 38 | strGen = stringgenerator.NewMock() 39 | mockMailer = mailer.NewMockMailer() 40 | stringgenerator.SetMockResult("this_is_not_a_random_string") 41 | mockStorageProvider := storageprovider.NewMock() 42 | storageProviderPool.Register(mockStorageProvider) 43 | 44 | var err error 45 | 46 | htmlTemplates, err = htmlTemplate. 47 | New("request_password_recovery_html"). 48 | Parse("{{.Token}}") 49 | if err != nil { 50 | panic(err) 51 | } 52 | 53 | textTemplates, err = textTemplate. 54 | New("request_password_recovery_text"). 55 | Parse("{{.Token}}") 56 | if err != nil { 57 | panic(err) 58 | } 59 | } 60 | 61 | func newRepo() (domain.UserRepository, domain.UserStorageCredentialRepository) { 62 | memdb := inmemory.New() 63 | return inmemory.NewUserRepository(memdb), inmemory.NewUserStorageCredentialRepository(memdb) 64 | } 65 | 66 | func str2ptr(s string) *string { 67 | return &s 68 | } 69 | 70 | func time2ptr(t time.Time) *time.Time { 71 | return &t 72 | } 73 | 74 | func TestRegister(t *testing.T) { 75 | type test struct { 76 | email string 77 | name string 78 | password string 79 | wantUser *domain.User 80 | wantErr error 81 | } 82 | 83 | tests := []test{ 84 | {email: "user@drophere.link", name: "User", password: "123456", wantErr: domain.ErrUserDuplicated}, 85 | {email: "new_user@drophere.link", name: "New User", password: "123456", wantErr: nil}, 86 | } 87 | 88 | userRepo, userStorageCredRepo := newRepo() 89 | userSvc := user.NewService( 90 | userRepo, 91 | userStorageCredRepo, 92 | authenticator, 93 | mockMailer, 94 | dummyHasher, 95 | strGen, 96 | storageProviderPool, 97 | htmlTemplates, 98 | textTemplates, 99 | user.Config{}, 100 | ) 101 | 102 | for i, tc := range tests { 103 | _, gotErr := userSvc.Register(tc.email, tc.name, tc.password) 104 | if gotErr != tc.wantErr { 105 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantErr, gotErr) 106 | } 107 | } 108 | } 109 | 110 | func TestAuth(t *testing.T) { 111 | type test struct { 112 | email string 113 | password string 114 | wantCreds *domain.UserCredentials 115 | wantErr error 116 | } 117 | 118 | tests := []test{ 119 | {email: "", password: "", wantErr: domain.ErrUserNotFound}, 120 | {email: "user@drophere.link", password: "", wantErr: domain.ErrUserInvalidPassword}, 121 | {email: "user@drophere.link", password: "123456", wantCreds: &domain.UserCredentials{Token: "user_token_1"}}, 122 | } 123 | 124 | userRepo, userStorageCredRepo := newRepo() 125 | userSvc := user.NewService( 126 | userRepo, 127 | userStorageCredRepo, 128 | authenticator, 129 | mockMailer, 130 | dummyHasher, 131 | strGen, 132 | storageProviderPool, 133 | htmlTemplates, 134 | textTemplates, 135 | user.Config{}, 136 | ) 137 | 138 | for i, tc := range tests { 139 | gotCreds, gotErr := userSvc.Auth(tc.email, tc.password) 140 | if gotErr != tc.wantErr { 141 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantErr, gotErr) 142 | } 143 | if gotCreds != nil && gotCreds.Token != tc.wantCreds.Token { 144 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantCreds.Token, gotCreds.Token) 145 | } 146 | } 147 | } 148 | 149 | func TestUpdateStorageToken(t *testing.T) { 150 | type test struct { 151 | userID uint 152 | dropboxToken *string 153 | wantUser *domain.User 154 | wantErr error 155 | } 156 | 157 | userRepo, userStorageCredRepo := newRepo() 158 | u, _ := userRepo.FindByID(1) 159 | 160 | tests := []test{ 161 | {userID: 123, wantErr: domain.ErrUserNotFound, wantUser: nil}, 162 | { 163 | userID: 1, 164 | dropboxToken: str2ptr("my_dropbox_token_here"), 165 | wantUser: &domain.User{ 166 | ID: u.ID, 167 | Email: u.Email, 168 | Name: u.Name, 169 | Password: u.Password, 170 | DropboxToken: str2ptr("my_dropbox_token_here"), 171 | }, 172 | }, 173 | } 174 | 175 | userSvc := user.NewService( 176 | userRepo, 177 | userStorageCredRepo, 178 | authenticator, 179 | mockMailer, 180 | dummyHasher, 181 | strGen, 182 | storageProviderPool, 183 | htmlTemplates, 184 | textTemplates, 185 | user.Config{}, 186 | ) 187 | 188 | for i, tc := range tests { 189 | gotUser, gotErr := userSvc.UpdateStorageToken(tc.userID, tc.dropboxToken) 190 | if gotErr != tc.wantErr { 191 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantErr, gotErr) 192 | } 193 | if !reflect.DeepEqual(gotUser, tc.wantUser) { 194 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantUser, gotUser) 195 | } 196 | } 197 | } 198 | 199 | func TestUpdate(t *testing.T) { 200 | type test struct { 201 | userID uint 202 | name *string 203 | password *string 204 | oldPassword *string 205 | wantUser *domain.User 206 | wantErr error 207 | } 208 | 209 | userRepo, userStorageCredRepo := newRepo() 210 | u, _ := userRepo.FindByID(1) 211 | 212 | tests := []test{ 213 | {userID: 123, wantErr: domain.ErrUserNotFound, wantUser: nil}, 214 | {userID: 1, password: str2ptr("new_password123"), oldPassword: nil, wantErr: domain.ErrUserInvalidPassword}, 215 | {userID: 1, password: str2ptr("new_password123"), oldPassword: str2ptr(""), wantErr: domain.ErrUserInvalidPassword}, 216 | { 217 | userID: 1, 218 | name: str2ptr("new name 123"), 219 | password: str2ptr("new_password123"), 220 | oldPassword: str2ptr("123456"), 221 | wantUser: &domain.User{ 222 | ID: u.ID, 223 | Email: u.Email, 224 | Name: "new name 123", 225 | Password: "new_password123", 226 | }, 227 | }, 228 | } 229 | 230 | userSvc := user.NewService( 231 | userRepo, 232 | userStorageCredRepo, 233 | authenticator, 234 | mockMailer, 235 | dummyHasher, 236 | strGen, 237 | storageProviderPool, 238 | htmlTemplates, 239 | textTemplates, 240 | user.Config{}, 241 | ) 242 | 243 | for i, tc := range tests { 244 | gotUser, gotErr := userSvc.Update(tc.userID, tc.name, tc.password, tc.oldPassword) 245 | if gotErr != tc.wantErr { 246 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantErr, gotErr) 247 | } 248 | if !reflect.DeepEqual(gotUser, tc.wantUser) { 249 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantUser, gotUser) 250 | } 251 | } 252 | } 253 | 254 | func TestRequestPasswordRecovery(t *testing.T) { 255 | type test struct { 256 | email string 257 | wantErr error 258 | } 259 | 260 | userRepo, userStorageCredRepo := newRepo() 261 | u, _ := userRepo.FindByEmail("reset+pwd@drophere.link") 262 | expectedToken := str2ptr("this_is_not_a_random_string") 263 | emailHTMLTemplate := htmlTemplates.Lookup("request_password_recovery_html") 264 | emailTextTemplate := textTemplates.Lookup("request_password_recovery_text") 265 | templateContent := map[string]string{ 266 | "Token": *expectedToken, 267 | } 268 | 269 | expectedHTMLEmailMessage := &bytes.Buffer{} 270 | emailHTMLTemplate.Execute(expectedHTMLEmailMessage, templateContent) 271 | expectedTextEmailMessage := &bytes.Buffer{} 272 | emailTextTemplate.Execute(expectedTextEmailMessage, templateContent) 273 | 274 | expectedMail := mailer.MockMessage{ 275 | From: "admin@drophere.link", 276 | To: "reset+pwd@drophere.link", 277 | Title: "Recover Password", 278 | MessagePlain: expectedTextEmailMessage.String(), 279 | MessageHTML: expectedHTMLEmailMessage.String(), 280 | } 281 | 282 | tests := []test{ 283 | {email: "", wantErr: domain.ErrUserNotFound}, 284 | {email: "reset+pwd@drophere.link", wantErr: nil}, 285 | } 286 | 287 | userSvc := user.NewService( 288 | userRepo, 289 | userStorageCredRepo, 290 | authenticator, 291 | mockMailer, 292 | dummyHasher, 293 | strGen, 294 | storageProviderPool, 295 | htmlTemplates, 296 | textTemplates, 297 | user.Config{}, 298 | ) 299 | 300 | for i, tc := range tests { 301 | // reset inbox 302 | mailer.ClearMessages() 303 | 304 | gotErr := userSvc.RequestPasswordRecovery(tc.email) 305 | if gotErr != tc.wantErr { 306 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantErr, gotErr) 307 | } 308 | 309 | if gotErr == nil { 310 | if !reflect.DeepEqual(u.RecoverPasswordToken, expectedToken) { 311 | t.Fatalf("test %d: expected: %v, got: %v", i, expectedToken, u.RecoverPasswordToken) 312 | } 313 | // TODO: Mock time using https://github.com/bouk/monkey 314 | if !reflect.DeepEqual(mailer.MockMessages[0], expectedMail) { 315 | t.Fatalf("test %d: expected: %v, got: %v", i, expectedMail, mailer.MockMessages[0]) 316 | } 317 | } 318 | 319 | } 320 | } 321 | 322 | func TestRecoverPassword(t *testing.T) { 323 | type test struct { 324 | email string 325 | token string 326 | newPassword string 327 | expectedUser *domain.User 328 | wantErr error 329 | } 330 | 331 | recoverPasswordToken := "this_is_a_recover_password_token" 332 | 333 | userRepo, userStorageCredRepo := newRepo() 334 | u, _ := userRepo.FindByEmail("reset+pwd@drophere.link") 335 | u.RecoverPasswordToken = str2ptr(recoverPasswordToken) 336 | u.RecoverPasswordTokenExpiry = time2ptr(time.Now().Add(30 * time.Minute)) 337 | 338 | expiredRPTUser, _ := userRepo.FindByEmail("reset+pwd+expired_token@drophere.link") 339 | expiredRPTUser.RecoverPasswordToken = str2ptr(recoverPasswordToken) 340 | 341 | tests := []test{ 342 | {email: "", wantErr: domain.ErrUserNotFound}, 343 | {email: "reset+pwd@drophere.link", token: "", wantErr: domain.ErrUserNotFound}, 344 | // {email: "reset+pwd@drophere.link", token: recoverPasswordToken, newPassword: "", wantErr: domain.ErrUserNotFound}, 345 | { 346 | email: "reset+pwd+expired_token@drophere.link", 347 | token: recoverPasswordToken, 348 | newPassword: "new_password_for_this_user", 349 | wantErr: domain.ErrUserPasswordRecoveryTokenExpired, 350 | }, 351 | { 352 | email: "reset+pwd@drophere.link", 353 | token: recoverPasswordToken, 354 | newPassword: "new_password_for_this_user", 355 | expectedUser: &domain.User{ 356 | ID: u.ID, 357 | Email: u.Email, 358 | Name: u.Name, 359 | Password: "new_password_for_this_user", 360 | RecoverPasswordToken: nil, 361 | RecoverPasswordTokenExpiry: nil, 362 | }, 363 | wantErr: nil, 364 | }, 365 | } 366 | 367 | userSvc := user.NewService( 368 | userRepo, 369 | userStorageCredRepo, 370 | authenticator, 371 | mockMailer, 372 | dummyHasher, 373 | strGen, 374 | storageProviderPool, 375 | htmlTemplates, 376 | textTemplates, 377 | user.Config{}, 378 | ) 379 | 380 | for i, tc := range tests { 381 | 382 | gotErr := userSvc.RecoverPassword(tc.email, tc.token, tc.newPassword) 383 | if gotErr != tc.wantErr { 384 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantErr, gotErr) 385 | } 386 | 387 | if gotErr == nil { 388 | if !reflect.DeepEqual(u, tc.expectedUser) { 389 | t.Fatalf("test %d: expected: %+v, got: %+v", i, tc.expectedUser, u) 390 | } 391 | } 392 | 393 | } 394 | } 395 | 396 | func TestConnectStorageProvider(t *testing.T) { 397 | type test struct { 398 | userID uint 399 | providerID uint 400 | providerCredential string 401 | accountInfo domain.StorageProviderAccountInfo 402 | wantErr error 403 | } 404 | 405 | userRepo, userStorageCredRepo := newRepo() 406 | // user1, _ := userRepo.FindByID(1) 407 | 408 | tests := []test{ 409 | { 410 | userID: 123, 411 | wantErr: domain.ErrStorageProviderInvalid, 412 | }, 413 | { 414 | userID: 123, 415 | providerID: 1, 416 | wantErr: domain.ErrUserNotFound, 417 | }, 418 | { 419 | // update existing token 420 | userID: 1, 421 | providerID: 1, 422 | providerCredential: "dropboxToken+0xbadc0de", 423 | accountInfo: domain.StorageProviderAccountInfo{ 424 | Email: "user_1_another_email@drophere.link", 425 | Photo: "https://my.photo/user_1.jpg", 426 | }, 427 | wantErr: nil, 428 | }, 429 | { 430 | // create new record 431 | userID: 357, 432 | providerID: 1, 433 | providerCredential: "mockStorageToken+0xbadc0de", 434 | accountInfo: domain.StorageProviderAccountInfo{ 435 | Email: "user_357_another_email@drophere.link", 436 | Photo: "https://my.photo/user_357.jpg", 437 | }, 438 | wantErr: nil, 439 | }, 440 | } 441 | 442 | userSvc := user.NewService( 443 | userRepo, 444 | userStorageCredRepo, 445 | authenticator, 446 | mockMailer, 447 | dummyHasher, 448 | strGen, 449 | storageProviderPool, 450 | htmlTemplates, 451 | textTemplates, 452 | user.Config{}, 453 | ) 454 | 455 | for i, tc := range tests { 456 | 457 | storageprovider.SetSharedAccountInfo(tc.accountInfo) 458 | 459 | gotErr := userSvc.ConnectStorageProvider(tc.userID, tc.providerID, tc.providerCredential) 460 | if gotErr != tc.wantErr { 461 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantErr, gotErr) 462 | } 463 | 464 | if gotErr == nil { 465 | ucs, _ := userStorageCredRepo.Find(domain.UserStorageCredentialFilters{ 466 | UserIDs: []uint{tc.userID}, 467 | }, false) 468 | 469 | if tc.providerCredential != ucs[0].ProviderCredential { 470 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.providerCredential, ucs[0].ProviderCredential) 471 | } 472 | 473 | if tc.accountInfo.Email != ucs[0].Email { 474 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.accountInfo.Email, ucs[0].Email) 475 | } 476 | 477 | if tc.accountInfo.Photo != ucs[0].Photo { 478 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.accountInfo.Photo, ucs[0].Photo) 479 | } 480 | 481 | } 482 | 483 | } 484 | } 485 | 486 | func TestDisconnectStorageProvider(t *testing.T) { 487 | type test struct { 488 | userID uint 489 | providerID uint 490 | wantErr error 491 | } 492 | 493 | userRepo, userStorageCredRepo := newRepo() 494 | // user1, _ := userRepo.FindByID(1) 495 | 496 | tests := []test{ 497 | { 498 | userID: 123, 499 | wantErr: domain.ErrStorageProviderInvalid, 500 | }, 501 | { 502 | userID: 123, 503 | providerID: 1, 504 | wantErr: domain.ErrUserNotFound, 505 | }, 506 | { 507 | // delete existing token 508 | userID: 1, 509 | providerID: 1, 510 | wantErr: nil, 511 | }, 512 | { 513 | // delete empty record 514 | userID: 357, 515 | providerID: 1, 516 | wantErr: nil, 517 | }, 518 | } 519 | 520 | userSvc := user.NewService( 521 | userRepo, 522 | userStorageCredRepo, 523 | authenticator, 524 | mockMailer, 525 | dummyHasher, 526 | strGen, 527 | storageProviderPool, 528 | htmlTemplates, 529 | textTemplates, 530 | user.Config{}, 531 | ) 532 | 533 | for i, tc := range tests { 534 | 535 | gotErr := userSvc.DisconnectStorageProvider(tc.userID, tc.providerID) 536 | if gotErr != tc.wantErr { 537 | t.Fatalf("test %d: expected: %v, got: %v", i, tc.wantErr, gotErr) 538 | } 539 | 540 | if gotErr == nil { 541 | ucs, _ := userStorageCredRepo.Find(domain.UserStorageCredentialFilters{ 542 | UserIDs: []uint{tc.userID}, 543 | ProviderIDs: []uint{tc.providerID}, 544 | }, false) 545 | 546 | if len(ucs) > 0 { 547 | t.Fatalf("test %d: expected: %v, got: %v", i, nil, ucs) 548 | } 549 | 550 | } 551 | 552 | } 553 | } 554 | 555 | func TestListStorageProviders(t *testing.T) { 556 | type test struct { 557 | userID uint 558 | expectedProviders []domain.UserStorageCredential 559 | wantErr error 560 | } 561 | 562 | userRepo, userStorageCredRepo := newRepo() 563 | uscsUser1, _ := userStorageCredRepo.Find(domain.UserStorageCredentialFilters{ 564 | UserIDs: []uint{1}, 565 | }, false) 566 | 567 | tests := []test{ 568 | { 569 | // expect empty 570 | userID: 123, 571 | expectedProviders: []domain.UserStorageCredential{}, 572 | wantErr: nil, 573 | }, 574 | { 575 | // expect non-empty credential 576 | userID: 1, 577 | expectedProviders: uscsUser1, 578 | wantErr: nil, 579 | }, 580 | } 581 | 582 | userSvc := user.NewService( 583 | userRepo, 584 | userStorageCredRepo, 585 | authenticator, 586 | mockMailer, 587 | dummyHasher, 588 | strGen, 589 | storageProviderPool, 590 | htmlTemplates, 591 | textTemplates, 592 | user.Config{}, 593 | ) 594 | 595 | for _, tc := range tests { 596 | 597 | uscs, gotErr := userSvc.ListStorageProviders(tc.userID) 598 | assert.ElementsMatch(t, tc.expectedProviders, uscs) 599 | assert.Equal(t, tc.wantErr, gotErr) 600 | 601 | } 602 | } 603 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= 4 | cloud.google.com/go v0.39.0 h1:UgQP9na6OTfp4dsAiz/eFpFA1C6tPdH5wiRdi19tuMw= 5 | cloud.google.com/go v0.39.0/go.mod h1:rVLT6fkc8chs9sfPtFc1SBH6em7n+ZoXaG+87tDISts= 6 | github.com/99designs/gqlgen v0.9.0 h1:g1arBPML74Vqv0L3Q+TqIhGXLspV+2MYtRLkBxuZrlE= 7 | github.com/99designs/gqlgen v0.9.0/go.mod h1:HrrG7ic9EgLPsULxsZh/Ti+p0HNWgR3XRuvnD0pb5KY= 8 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 9 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 10 | github.com/DataDog/zstd v1.3.6-0.20190409195224-796139022798/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= 11 | github.com/DataDog/zstd v1.4.0/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= 12 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 13 | github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= 14 | github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= 15 | github.com/Shopify/sarama v1.22.1/go.mod h1:FRzlvRpMFO/639zY1SDxUxkqH97Y0ndM5CbGj6oG3As= 16 | github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= 17 | github.com/agnivade/levenshtein v1.0.1 h1:3oJU7J3FGFmyhn8KHjmVaZCN5hxTr7GxgRue+sxIXdQ= 18 | github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= 19 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 20 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 21 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 22 | github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= 23 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 24 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 25 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 26 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 27 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 28 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= 29 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 30 | github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 31 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 32 | github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 33 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 34 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 35 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 36 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 37 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 38 | github.com/denisenkom/go-mssqldb v0.0.0-20190423183735-731ef375ac02/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= 39 | github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 h1:tkum0XDgfR0jcVVXuTsYv/erY2NnEDqwRojbxR1rBYA= 40 | github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= 41 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 42 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 43 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 44 | github.com/dgryski/go-sip13 v0.0.0-20190329191031-25c5027a8c7b/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 45 | github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= 46 | github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= 47 | github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= 48 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= 49 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= 50 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 51 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 52 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 53 | github.com/go-chi/chi v3.3.2+incompatible h1:uQNcQN3NsV1j4ANsPh42P4ew4t6rnRbJb8frvpp31qQ= 54 | github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= 55 | github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= 56 | github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= 57 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 58 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 59 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 60 | github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= 61 | github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 62 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 63 | github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= 64 | github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 65 | github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 66 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 67 | github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 68 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 69 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 70 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 71 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 72 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 73 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 74 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 75 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 76 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 77 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 78 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 79 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 80 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 81 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 82 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 83 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 84 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 85 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 86 | github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 87 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 88 | github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 89 | github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 90 | github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 91 | github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ= 92 | github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 93 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 94 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 95 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= 96 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 97 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= 98 | github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= 99 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 100 | github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= 101 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 102 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 103 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 104 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 105 | github.com/jinzhu/gorm v1.9.8 h1:n5uvxqLepIP2R1XF7pudpt9Rv8I3m7G9trGxJVjLZ5k= 106 | github.com/jinzhu/gorm v1.9.8/go.mod h1:bdqTT3q6dhSph2K3pWxrHP6nqxuAp2yQ3KFtc3U3F84= 107 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a h1:eeaG9XMUvRBYXJi4pg1ZKM7nxc5AfXfojeLLW7O5J3k= 108 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 109 | github.com/jinzhu/now v1.0.0 h1:6WV8LvwPpDhKjo5U9O6b4+xdG/jTXNPwlDme/MTo8Ns= 110 | github.com/jinzhu/now v1.0.0/go.mod h1:oHTiXerJ20+SfYcrdlBO7rzZRJWGwSTQ0iUY2jI6Gfc= 111 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 112 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 113 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 114 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 115 | github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= 116 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 117 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 118 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 119 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 120 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 121 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 122 | github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 123 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 124 | github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 125 | github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= 126 | github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 127 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 128 | github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= 129 | github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 130 | github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= 131 | github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 132 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 133 | github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 134 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 135 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 136 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 137 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 138 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 139 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 140 | github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 141 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 142 | github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 143 | github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= 144 | github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= 145 | github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= 146 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 147 | github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg= 148 | github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= 149 | github.com/pierrec/lz4 v0.0.0-20190327172049-315a67e90e41/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= 150 | github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= 151 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 152 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 153 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 154 | github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= 155 | github.com/pkg/profile v1.3.0/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= 156 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 157 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 158 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 159 | github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= 160 | github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= 161 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 162 | github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 163 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 164 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 165 | github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 166 | github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 167 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 168 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 169 | github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 170 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 171 | github.com/prometheus/procfs v0.0.0-20190523193104-a7aeb8df3389/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 172 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 173 | github.com/prometheus/tsdb v0.8.0/go.mod h1:fSI0j+IUQrDd7+ZtR9WKIGtoYAYAJUKcKhYLG25tN4g= 174 | github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= 175 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 176 | github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= 177 | github.com/rs/cors v1.6.0 h1:G9tHG9lebljV9mfp9SNPDL36nCDxmo3zTlAf1YgvzmI= 178 | github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= 179 | github.com/sendgrid/rest v2.4.1+incompatible h1:HDib/5xzQREPq34lN3YMhQtMkdXxS/qLp5G3k9a5++4= 180 | github.com/sendgrid/rest v2.4.1+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= 181 | github.com/sendgrid/sendgrid-go v3.5.0+incompatible h1:kosbgHyNVYVaqECDYvFVLVD9nvThweBd6xp7vaCT3GI= 182 | github.com/sendgrid/sendgrid-go v3.5.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= 183 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 184 | github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= 185 | github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= 186 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 187 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 188 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= 189 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 190 | github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 191 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 192 | github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= 193 | github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 194 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 195 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 196 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 197 | github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= 198 | github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= 199 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 200 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 201 | github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= 202 | github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= 203 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 204 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 205 | github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= 206 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 207 | github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 208 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 209 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 210 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 211 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 212 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 213 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 214 | github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= 215 | github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= 216 | github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= 217 | github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= 218 | github.com/vektah/gqlparser v1.1.2 h1:ZsyLGn7/7jDNI+y4SEhI4yAxRChlv15pUHMjijT+e68= 219 | github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= 220 | github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= 221 | github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= 222 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 223 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 224 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 225 | go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= 226 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 227 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 228 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 229 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 230 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 231 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 232 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 233 | golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 234 | golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f h1:R423Cnkcp5JABoeemiGEPlt9tHXFfw5kvc0yqlxRPWo= 235 | golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 236 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 237 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 238 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 239 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 240 | golang.org/x/image v0.0.0-20190523035834-f03afa92d3ff/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 241 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 242 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 243 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 244 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 245 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 246 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 247 | golang.org/x/mobile v0.0.0-20190509164839-32b2708ab171/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 248 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 249 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 250 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 251 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 252 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 253 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 254 | golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 255 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 256 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 257 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 258 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco= 259 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 260 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 261 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 262 | golang.org/x/oauth2 v0.0.0-20190523182746-aaccbc9213b0/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 263 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 264 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 265 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 266 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 267 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 268 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 269 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 270 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 271 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 272 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 273 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 274 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 275 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 276 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 277 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 278 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 279 | golang.org/x/sys v0.0.0-20190527104216-9cd6430ef91e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 280 | golang.org/x/sys v0.0.0-20190528012530-adf421d2caf4 h1:gd52YanAQJ4UkvuNi/7z63JEyc6ejHh9QwdzbTiEtAY= 281 | golang.org/x/sys v0.0.0-20190528012530-adf421d2caf4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 282 | golang.org/x/sys v0.0.0-20190528183647-3626398d7749 h1:oG2HS+e2B9VqK95y67B5MgJIJhOPY27/m5uJKJhHzus= 283 | golang.org/x/sys v0.0.0-20190528183647-3626398d7749/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 284 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 285 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 286 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 287 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 288 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 289 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 290 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 291 | golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 292 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 293 | golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 294 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 295 | golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 296 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 297 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 298 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 299 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 300 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 301 | golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd h1:oMEQDWVXVNpceQoVd1JN3CQ7LYJJzs5qWqZIUcxXHHw= 302 | golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 303 | golang.org/x/tools v0.0.0-20190525145741-7be61e1b0e51 h1:RhYYBLDB5MoVkvoNGMNk+DSj7WoGhySvIvtEjTyiP74= 304 | golang.org/x/tools v0.0.0-20190525145741-7be61e1b0e51/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 305 | golang.org/x/tools v0.0.0-20190529010454-aa71c3f32488 h1:vBgi/AgEje1rNScpWGJqe+RPHHZvBqrk9UH+LOXWN6Q= 306 | golang.org/x/tools v0.0.0-20190529010454-aa71c3f32488/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 307 | google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= 308 | google.golang.org/api v0.5.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 309 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 310 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 311 | google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 312 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 313 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 314 | google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 315 | google.golang.org/genproto v0.0.0-20190508193815-b515fa19cec8/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 316 | google.golang.org/genproto v0.0.0-20190522204451-c2c4e71fbf69/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= 317 | google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= 318 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 319 | google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 320 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 321 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 322 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 323 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 324 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= 325 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= 326 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= 327 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 328 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 329 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 330 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 331 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 332 | honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 333 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 334 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 335 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 336 | sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= 337 | sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k= 338 | --------------------------------------------------------------------------------