├── .gitignore ├── models ├── migrations │ ├── 20201028201449_add_avatar_to_users.go │ ├── 20201127200015_create_comments.go │ ├── 20201028195000_create_users.go │ ├── 20201102233528_create_sessions.go │ ├── 20201029213046_change_status_on_users.go │ ├── 20201114111651_create_marks.go │ ├── init.go │ └── 20201129222847_add_unique_indices.go ├── selection.go ├── session.go ├── base.go ├── callback.go ├── init.go ├── test_helper.go ├── comment.go ├── user.go ├── soft_delete.go └── mark.go ├── controllers ├── root.go ├── root_test.go ├── api │ ├── v1 │ │ ├── sessions_controller_test.go │ │ ├── sessions_controller.go │ │ ├── users_controller.go │ │ ├── users_controller_test.go │ │ ├── comments_controller_test.go │ │ ├── marks_controller.go │ │ ├── comments_controller.go │ │ └── marks_controller_test.go │ └── api.go └── middleware │ ├── current_user.go │ └── current_user_test.go ├── errs └── error.go ├── utils ├── token.go ├── path_test.go ├── path.go └── token_test.go ├── routes ├── cors.go ├── init.go └── routes.go ├── bin └── migrate_cmd.go ├── go.mod ├── main.go ├── config ├── init_test.go └── init.go ├── config.yaml ├── indices ├── comment_index.go ├── init.go ├── mark_index_test.go └── mark_index.go ├── LICENSE ├── .github └── workflows │ └── test.yml ├── readme.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | test.db 3 | -------------------------------------------------------------------------------- /models/migrations/20201028201449_add_avatar_to_users.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | func addAvatarToUsers() error { 4 | type User struct { 5 | Avatar string `gorm:"type:text;comment:Avatar URL"` 6 | } 7 | return mm.ChangeColumn(&User{}, "Avatar") 8 | } 9 | -------------------------------------------------------------------------------- /controllers/root.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "time" 6 | ) 7 | 8 | // root and health check 9 | func Root(c *gin.Context) { 10 | c.JSON(200, gin.H{ 11 | "health": "ok", 12 | "now": time.Now().Format(time.RFC3339), 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /errs/error.go: -------------------------------------------------------------------------------- 1 | package errs 2 | 3 | const ( 4 | Parameters = iota 5 | Unauthorized 6 | Forbidden 7 | Validation 8 | NotFound 9 | Internal 10 | ) 11 | 12 | // error response 13 | type E struct { 14 | C int `json:"code"` // Business Code 15 | M []string `json:"messages"` // Error Messages 16 | } 17 | -------------------------------------------------------------------------------- /utils/token.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | ) 7 | 8 | // GenerateToken return 2*length string 9 | func GenerateToken(length int) string { 10 | b := make([]byte, length) 11 | if _, err := rand.Read(b); err != nil { 12 | return "" 13 | } 14 | return hex.EncodeToString(b) 15 | } 16 | -------------------------------------------------------------------------------- /utils/path_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func init() { 10 | gin.SetMode(gin.TestMode) 11 | } 12 | 13 | func TestRootPath(t *testing.T) { 14 | assert := assert.New(t) 15 | assert.Contains(RootPath(), RootName) 16 | } 17 | -------------------------------------------------------------------------------- /routes/cors.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/gin-contrib/cors" 5 | "github.com/gin-gonic/gin" 6 | ) 7 | 8 | func CORS(r *gin.Engine) { 9 | config := cors.DefaultConfig() 10 | config.AllowBrowserExtensions = true 11 | config.AllowOrigins = []string{"chrome-extension://homlcfpinafhealhlmjkmdjdejppmmlk"} 12 | r.Use(cors.New(config)) 13 | } 14 | -------------------------------------------------------------------------------- /routes/init.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | var r = gin.New() 8 | 9 | func init() { 10 | r.Use(gin.Logger()) 11 | r.Use(gin.Recovery()) 12 | r.MaxMultipartMemory = 1 << 20 // 1 MiB 13 | 14 | CORS(r) 15 | Root(r) 16 | ApiV1Public(r) 17 | ApiV1Auth(r) 18 | } 19 | 20 | func R() *gin.Engine { 21 | return r 22 | } 23 | -------------------------------------------------------------------------------- /models/migrations/20201127200015_create_comments.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/icbd/gohighlights/models" 5 | ) 6 | 7 | func createComments() error { 8 | type Comment struct { 9 | models.BaseModel 10 | UserID uint `gorm:"not null"` 11 | MarkID uint `gorm:"not null"` 12 | Content string `gorm:"type:text"` 13 | } 14 | return mm.ChangeTable(&Comment{}) 15 | } 16 | -------------------------------------------------------------------------------- /utils/path.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | ) 7 | 8 | const RootName = "gohighlights" 9 | 10 | func RootPath() (rootPath string) { 11 | dir, _ := os.Getwd() 12 | for _, s := range strings.Split(dir, string(os.PathSeparator)) { 13 | rootPath += string(os.PathSeparator) 14 | rootPath += s 15 | if s == RootName { 16 | break 17 | } 18 | } 19 | return rootPath 20 | } 21 | -------------------------------------------------------------------------------- /utils/token_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func init() { 10 | gin.SetMode(gin.TestMode) 11 | } 12 | 13 | func TestGenerateToken(t *testing.T) { 14 | assert := assert.New(t) 15 | 16 | t1 := GenerateToken(10) 17 | t2 := GenerateToken(10) 18 | assert.NotEqual(t1, t2) 19 | assert.Equal(20, len(t1)) 20 | } 21 | -------------------------------------------------------------------------------- /models/migrations/20201028195000_create_users.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import "github.com/icbd/gohighlights/models" 4 | 5 | func createUsers() error { 6 | type User struct { 7 | models.BaseModel 8 | Status string `gorm:"type:varchar(255);index"` 9 | Email string `gorm:"type:varchar(255)"` 10 | PasswordHash string `gorm:"type:text;comment:BCrypt"` 11 | } 12 | return mm.ChangeTable(&User{}) 13 | } 14 | -------------------------------------------------------------------------------- /bin/migrate_cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "github.com/icbd/gohighlights/models" 6 | "github.com/icbd/gohighlights/models/migrations" 7 | mgr "github.com/icbd/gorm-migration" 8 | "log" 9 | ) 10 | 11 | var migrateType mgr.MigrateType 12 | 13 | func main() { 14 | flag.Var(&migrateType, "db", "-db=migrate") 15 | flag.Parse() 16 | 17 | if err := models.Ping(); err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | migrations.Migrate(migrateType) 22 | } 23 | -------------------------------------------------------------------------------- /models/migrations/20201102233528_create_sessions.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/icbd/gohighlights/models" 5 | "time" 6 | ) 7 | 8 | func createSessions() error { 9 | type Session struct { 10 | models.BaseModel 11 | Token string `json:"token" gorm:"type:varchar(255);not null"` 12 | ExpiredAt time.Time `json:"expired_at" gorm:"not null"` 13 | UserID uint `json:"user_id" gorm:"not null"` 14 | } 15 | return mm.ChangeTable(&Session{}) 16 | } 17 | -------------------------------------------------------------------------------- /models/migrations/20201029213046_change_status_on_users.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | /* 4 | Remove this migration. Sqlite don't support DROP COLUMN command. 5 | */ 6 | func changeStatusOnUsers() error { 7 | up := mm.ChangeFuncWrap( 8 | `ALTER TABLE users DROP COLUMN status;`, 9 | `ALTER TABLE users ADD COLUMN status VARCHAR(15), ADD INDEX idx_users_status ( status );`, 10 | ) 11 | down := mm.ChangeFuncWrap( 12 | `ALTER TABLE users DROP COLUMN status;`, 13 | `ALTER TABLE users ADD COLUMN status TINYINT;`, 14 | ) 15 | return mm.Change(up, down) 16 | } 17 | -------------------------------------------------------------------------------- /models/migrations/20201114111651_create_marks.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/icbd/gohighlights/models" 5 | ) 6 | 7 | func createMarks() error { 8 | type Mark struct { 9 | models.BaseModel 10 | UserID uint `gorm:"not null"` 11 | URL string `gorm:"type:varchar(255);index;not null"` 12 | Tag string `gorm:"type:varchar(255);comment:color or other tag;not null"` 13 | HashKey string `gorm:"type:varchar(255);not null"` 14 | Selection string `gorm:"type:text;not null"` 15 | } 16 | return mm.ChangeTable(&Mark{}) 17 | } 18 | -------------------------------------------------------------------------------- /models/migrations/init.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/icbd/gohighlights/models" 5 | mgr "github.com/icbd/gorm-migration" 6 | ) 7 | 8 | var mm *mgr.MigrationManger 9 | 10 | func init() { 11 | mm = mgr.NewMigrationManger(models.DB(), mgr.Check) 12 | mm.RegisterFunctions( 13 | createUsers, 14 | addAvatarToUsers, 15 | createSessions, 16 | createMarks, 17 | createComments, 18 | addIndexOnUsers, 19 | addIndexOnSessions, 20 | addIndexOnMarks, 21 | addIndexOnComments, 22 | ) 23 | } 24 | 25 | func Migrate(t mgr.MigrateType) { 26 | mm.Type = t 27 | mm.Migrate() 28 | } 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/icbd/gohighlights 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/gin-contrib/cors v1.3.1 7 | github.com/gin-gonic/gin v1.6.3 8 | github.com/go-playground/validator/v10 v10.2.0 9 | github.com/icbd/gorm-migration v0.0.3 10 | github.com/jinzhu/copier v0.0.0-20201025035756-632e723a6687 11 | github.com/olivere/elastic/v7 v7.0.22 12 | github.com/spf13/cast v1.3.0 13 | github.com/spf13/viper v1.7.1 14 | github.com/stretchr/testify v1.6.1 15 | golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 16 | gorm.io/driver/mysql v1.0.3 17 | gorm.io/driver/sqlite v1.1.3 18 | gorm.io/gorm v1.20.5 19 | ) 20 | -------------------------------------------------------------------------------- /controllers/root_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/gin-gonic/gin" 6 | "github.com/stretchr/testify/assert" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | ) 11 | 12 | func init() { 13 | gin.SetMode(gin.TestMode) 14 | } 15 | 16 | func TestRoot(t *testing.T) { 17 | assert := assert.New(t) 18 | 19 | w := httptest.NewRecorder() 20 | ctx, _ := gin.CreateTestContext(w) 21 | ctx.Request, _ = http.NewRequest("GET", "/", nil) 22 | 23 | Root(ctx) 24 | 25 | assert.Equal(200, w.Code) 26 | 27 | var body map[string]string 28 | _ = json.Unmarshal(w.Body.Bytes(), &body) 29 | assert.Equal("ok", body["health"]) 30 | } 31 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/icbd/gohighlights/config" 5 | "github.com/icbd/gohighlights/indices" 6 | "github.com/icbd/gohighlights/models" 7 | "github.com/icbd/gohighlights/models/migrations" 8 | "github.com/icbd/gohighlights/routes" 9 | mgr "github.com/icbd/gorm-migration" 10 | "log" 11 | "net/http" 12 | ) 13 | 14 | func main() { 15 | server := &http.Server{ 16 | Handler: routes.R(), 17 | Addr: config.GetString("app.addr"), 18 | } 19 | 20 | if err := models.Ping(); err != nil { 21 | log.Fatal(err) 22 | } 23 | 24 | migrations.Migrate(mgr.Check) 25 | indices.Ping() 26 | 27 | if err := server.ListenAndServe(); err != nil { 28 | log.Fatal(err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /models/selection.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "github.com/spf13/cast" 7 | ) 8 | 9 | type Selection struct { 10 | Texts []string `json:"texts" binding:"required"` 11 | StartOffset int `json:"startOffset"` 12 | EndOffset int `json:"endOffset" binding:"required"` 13 | } 14 | 15 | // Scan from DB 16 | func (s *Selection) Scan(value interface{}) error { 17 | v := cast.ToString(value) 18 | return json.Unmarshal([]byte(v), s) 19 | } 20 | 21 | // Save to DB 22 | func (s Selection) Value() (driver.Value, error) { 23 | if j, err := json.Marshal(s); err != nil { 24 | return nil, err 25 | } else { 26 | return string(j), nil 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /config/init_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/spf13/cast" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func init() { 11 | gin.SetMode(gin.TestMode) 12 | } 13 | 14 | func TestGet(t *testing.T) { 15 | assert := assert.New(t) 16 | assert.Equal("mysql", cast.ToString(Get("db.type"))) 17 | } 18 | 19 | func TestGetString(t *testing.T) { 20 | assert := assert.New(t) 21 | assert.Equal("mysql", GetString("db.type")) 22 | } 23 | 24 | func TestSet(t *testing.T) { 25 | assert := assert.New(t) 26 | configKey := "tempConfigKey" 27 | configValue := "TEMPCONFIGVALUE" 28 | Set(configKey, configValue) 29 | assert.Equal(configValue, GetString(configKey)) 30 | } 31 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | default: &default 2 | app: 3 | addr: "0.0.0.0:3000" 4 | db: 5 | type: {{ ENV "DB_TYPE" "sqlite" }} 6 | dsn: {{ ENV "DB_DSN" "gohighlight.db" }} 7 | es: 8 | enable: false 9 | url: {{ ENV "ES_URL" "http://localhost:9200" }} 10 | prefix: "" 11 | 12 | debug: 13 | <<: *default 14 | db: 15 | type: mysql 16 | dsn: "root:rootpassword@tcp(127.0.0.1:3306)/gohighlights?charset=utf8mb4&parseTime=True&loc=Local" 17 | es: 18 | enable: true 19 | url: http://localhost:9200 20 | prefix: "" 21 | 22 | release: 23 | <<: *default 24 | 25 | test: 26 | <<: *default 27 | db: 28 | type: mysql 29 | dsn: "root:rootpassword@tcp(127.0.0.1:3306)/gohighlights_test?charset=utf8mb4&parseTime=True&loc=Local" 30 | es: 31 | enable: true 32 | url: http://localhost:9200 33 | prefix: test_ -------------------------------------------------------------------------------- /controllers/api/v1/sessions_controller_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "github.com/gin-gonic/gin" 7 | "github.com/icbd/gohighlights/models" 8 | "github.com/stretchr/testify/assert" 9 | "net/http" 10 | "net/http/httptest" 11 | "testing" 12 | ) 13 | 14 | func init() { 15 | gin.SetMode(gin.TestMode) 16 | } 17 | 18 | func TestSessionsCreate(t *testing.T) { 19 | assert := assert.New(t) 20 | 21 | u := models.FakeUser() 22 | body, _ := json.Marshal(gin.H{"email": u.Email, "password": u.Password}) 23 | 24 | w := httptest.NewRecorder() 25 | ctx, _ := gin.CreateTestContext(w) 26 | ctx.Request = httptest.NewRequest("POST", "/api/v1/sessions", bytes.NewReader(body)) 27 | SessionsCreate(ctx) 28 | 29 | assert.Equal(http.StatusCreated, w.Code) 30 | 31 | session := models.Session{} 32 | _ = json.Unmarshal(w.Body.Bytes(), &session) 33 | assert.Equal(32, len(session.Token)) 34 | } 35 | -------------------------------------------------------------------------------- /controllers/api/v1/sessions_controller.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/icbd/gohighlights/controllers/api" 6 | "github.com/icbd/gohighlights/models" 7 | ) 8 | 9 | // POST /api/v1/sessions 10 | //{ 11 | // email: "", 12 | // password: "" 13 | //} 14 | func SessionsCreate(c *gin.Context) { 15 | resp := api.New(c) 16 | 17 | vo := models.SessionVO{} 18 | if err := c.BindJSON(&vo); err != nil { 19 | resp.ParametersErr(err) 20 | return 21 | } 22 | 23 | u, err := models.UserFindByEmail(vo.Email) 24 | if err != nil { 25 | // not found account 26 | resp.UnauthorizedErr() 27 | return 28 | } else { 29 | u.Password = vo.Password 30 | if !u.ValidPassword() { 31 | // password incorrect 32 | resp.UnauthorizedErr() 33 | return 34 | } 35 | } 36 | 37 | if s, err := u.GenerateSession(); err != nil { 38 | resp.ParametersErr(err) 39 | } else { 40 | resp.Created(s) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /models/session.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "gorm.io/gorm" 6 | "time" 7 | ) 8 | 9 | type SessionVO struct { 10 | Email string `json:"email" form:"email" binding:"required,email"` 11 | Password string `json:"password" form:"password" binding:"min=6"` 12 | } 13 | 14 | type Session struct { 15 | BaseModel 16 | Token string `json:"token" gorm:"uniqueindex;not null;type:varchar(255)"` 17 | ExpiredAt time.Time `json:"expired_at" gorm:"not null"` 18 | UserID uint `json:"user_id" gorm:"not null"` 19 | User *User `json:"user,omitempty"` 20 | } 21 | 22 | func SessionFindByToken(token string) (session *Session, err error) { 23 | if token == "" { 24 | return nil, gorm.ErrRecordNotFound 25 | } 26 | 27 | session = &Session{} 28 | if err := DB().Where("token = ?", token).Preload("User").First(session).Error; err != nil { 29 | return nil, err 30 | } 31 | 32 | if session.ExpiredAt.Before(time.Now()) { 33 | return nil, fmt.Errorf("token expired") 34 | } 35 | 36 | return session, nil 37 | } 38 | -------------------------------------------------------------------------------- /models/base.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | "time" 6 | ) 7 | 8 | type BaseModel struct { 9 | ID uint `gorm:"primarykey" json:"id"` 10 | CreatedAt time.Time `json:"-"` 11 | UpdatedAt time.Time `json:"-"` 12 | DeletedAt DeletedAt `gorm:"default:0;index" json:"-"` 13 | } 14 | 15 | func DB() *gorm.DB { 16 | return db 17 | } 18 | 19 | func Ping() error { 20 | sqlDB, err := db.DB() 21 | if err != nil { 22 | return err 23 | } 24 | if err := sqlDB.Ping(); err != nil { 25 | return err 26 | } 27 | 28 | return nil 29 | } 30 | 31 | type PaginationVO struct { 32 | Page int `json:"page" form:"page" binding:"min=1"` 33 | Size int `json:"size" form:"size" binding:"max=100"` 34 | } 35 | 36 | var Pagination = PaginationVO{Page: 1, Size: 10} 37 | 38 | func PaginationScope(vo PaginationVO) func(db *gorm.DB) *gorm.DB { 39 | return func(db *gorm.DB) *gorm.DB { 40 | if err := _validator.Struct(&vo); err != nil { 41 | return db.Limit(10).Offset(0) 42 | } 43 | return db.Limit(vo.Size).Offset(vo.Size * (vo.Page - 1)) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /indices/comment_index.go: -------------------------------------------------------------------------------- 1 | package indices 2 | 3 | import ( 4 | "context" 5 | "github.com/icbd/gohighlights/models" 6 | "github.com/olivere/elastic/v7" 7 | "github.com/spf13/cast" 8 | ) 9 | 10 | // CommentIndex is part of MarkIndex. 11 | // Only update CommentIndex on existed MarkIndex. 12 | type CommentIndex struct { 13 | err error 14 | indexID uint 15 | 16 | Comment string `json:"comment"` 17 | } 18 | 19 | func NewCommentIndex(c *models.Comment) *CommentIndex { 20 | return &CommentIndex{Comment: c.Content, indexID: c.MarkID} 21 | } 22 | 23 | /* 24 | POST /mark/_update/:markID 25 | { 26 | "doc": { 27 | "comment": "actually" 28 | } 29 | } 30 | */ 31 | func (commentIndex *CommentIndex) Update() (*elastic.UpdateResponse, error) { 32 | if !Enable { 33 | return nil, NotEnabledError 34 | } 35 | if commentIndex.err != nil { 36 | return nil, commentIndex.err 37 | } 38 | 39 | return Client(). 40 | Update(). 41 | Index(IndexName(MarkIndexName)). 42 | Id(cast.ToString(commentIndex.indexID)). 43 | Doc(commentIndex). 44 | Do(context.Background()) 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 BaoDong 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/icbd/gohighlights/controllers" 6 | v1 "github.com/icbd/gohighlights/controllers/api/v1" 7 | "github.com/icbd/gohighlights/controllers/middleware" 8 | ) 9 | 10 | func Root(r *gin.Engine) { 11 | r.GET("/", controllers.Root) 12 | } 13 | 14 | func ApiV1Public(r *gin.Engine) { 15 | rg := r.Group("/api/v1") 16 | 17 | rg.POST("/users", v1.UsersCreate) 18 | rg.POST("/sessions", v1.SessionsCreate) 19 | } 20 | 21 | func ApiV1Auth(r *gin.Engine) { 22 | rg := r.Group("/api/v1") 23 | rg.Use(middleware.CurrentUserMiddleware) 24 | 25 | rg.GET("/users/:id", v1.UsersShow) 26 | 27 | rg.POST("/marks", v1.MarksCreate) 28 | rg.DELETE("/marks/:hash_key", v1.MarksDestroy) 29 | rg.PATCH("/marks/:hash_key", v1.MarksUpdate) 30 | rg.GET("/marks/query", v1.MarksQuery) 31 | rg.GET("/marks/search", v1.MarksSearch) 32 | rg.GET("/marks", v1.MarksIndex) 33 | 34 | rg.POST("/marks/:hash_key/comment", v1.CommentsCreate) 35 | rg.PATCH("/marks/:hash_key/comment", v1.CommentsUpdate) 36 | rg.PUT("/marks/:hash_key/comment", v1.CommentsPut) 37 | rg.DELETE("/marks/:hash_key/comment", v1.CommentsDestroy) 38 | } 39 | -------------------------------------------------------------------------------- /controllers/middleware/current_user.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/icbd/gohighlights/models" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | const CurrentUser = "CurrentUser" 11 | 12 | type BearerVO struct { 13 | Bearer string `json:"bearer"` 14 | } 15 | 16 | func CurrentUserMiddleware(c *gin.Context) { 17 | bearer := "" 18 | splits := strings.Split(c.Request.Header.Get("Authorization"), " ") 19 | if len(splits) == 2 && splits[0] == "Bearer" && len(splits[1]) > 0 { 20 | bearer = splits[1] 21 | } 22 | 23 | //// query params 24 | //if bearer == "" { 25 | // bearer = c.Query("bearer") 26 | //} 27 | // 28 | //// request body 29 | //if bearer == "" && c.Request.Body != nil { 30 | // if data, err := ioutil.ReadAll(c.Request.Body); err == nil { 31 | // b := BearerVO{} 32 | // if err := json.Unmarshal(data, &b); err == nil { 33 | // bearer = b.Bearer 34 | // } 35 | // c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(data)) 36 | // } 37 | //} 38 | 39 | session, err := models.SessionFindByToken(bearer) 40 | if err != nil { 41 | c.AbortWithStatus(http.StatusUnauthorized) 42 | c.Abort() 43 | return 44 | } 45 | c.Set(CurrentUser, session.User) 46 | c.Next() 47 | } 48 | -------------------------------------------------------------------------------- /models/callback.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | func RegisterGormCallBack() (err error) { 9 | if err = db.Callback().Create().Before("gorm:begin_transaction").Register("uniqCheck", uniqCheck); err != nil { 10 | return err 11 | } 12 | if err = db.Callback().Update().Before("gorm:begin_transaction").Register("uniqCheck", uniqCheck); err != nil { 13 | return err 14 | } 15 | 16 | return nil 17 | } 18 | 19 | // uniqCheck is a callback function for Gorm. 20 | // Run query in a separate transaction before create/update transaction. 21 | // Final uniqueness is limited by the unique index of the database. 22 | func uniqCheck(db *gorm.DB) { 23 | schema := db.Statement.Schema 24 | for _, field := range schema.Fields { 25 | if field.TagSettings["UNIQUEINDEX"] == "UNIQUEINDEX" { 26 | var total int64 27 | fieldValue, _ := field.ValueOf(db.Statement.ReflectValue) 28 | err := db.Transaction(func(tx *gorm.DB) error { 29 | tx.Table(schema.Table).Where(field.DBName+" = ?", fieldValue).Count(&total) 30 | return tx.Error 31 | }) 32 | if err != nil { 33 | _ = db.AddError(err) 34 | return 35 | } 36 | if total != 0 { 37 | _ = db.AddError(fmt.Errorf(field.Name + " should be uniqueness")) 38 | return 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /models/migrations/20201129222847_add_unique_indices.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | func addIndexOnUsers() error { 4 | up := mm.ChangeFuncWrap( 5 | `CREATE UNIQUE INDEX idx_users_email_deletedAt ON users (email,deleted_at);`, 6 | ) 7 | down := mm.ChangeFuncWrap( 8 | `DROP INDEX idx_users_email_deletedAt ON users;`, 9 | ) 10 | return mm.Change(up, down) 11 | } 12 | 13 | func addIndexOnSessions() error { 14 | up := mm.ChangeFuncWrap( 15 | `CREATE UNIQUE INDEX idx_sessions_token_deletedAt ON sessions (token,deleted_at);`, 16 | ) 17 | down := mm.ChangeFuncWrap( 18 | `DROP INDEX idx_sessions_token_deletedAt ON sessions;`, 19 | ) 20 | return mm.Change(up, down) 21 | } 22 | 23 | func addIndexOnMarks() error { 24 | up := mm.ChangeFuncWrap( 25 | `CREATE UNIQUE INDEX idx_marks_userID_hashKey_deletedAt ON marks (user_id,hash_key,deleted_at);`, 26 | ) 27 | down := mm.ChangeFuncWrap( 28 | `DROP INDEX idx_marks_userID_hashKey_deletedAt ON marks;`, 29 | ) 30 | return mm.Change(up, down) 31 | } 32 | 33 | func addIndexOnComments() error { 34 | up := mm.ChangeFuncWrap( 35 | `CREATE UNIQUE INDEX idx_comments_markID_userID_deletedAt ON comments (mark_id,user_id,deleted_at);`, 36 | ) 37 | down := mm.ChangeFuncWrap( 38 | `DROP INDEX idx_comments_markID_userID_deletedAt ON comments;`, 39 | ) 40 | return mm.Change(up, down) 41 | } 42 | -------------------------------------------------------------------------------- /models/init.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/gin-gonic/gin/binding" 6 | "github.com/go-playground/validator/v10" 7 | "github.com/icbd/gohighlights/config" 8 | "gorm.io/driver/mysql" 9 | "gorm.io/driver/sqlite" 10 | "gorm.io/gorm" 11 | "gorm.io/gorm/logger" 12 | "log" 13 | ) 14 | 15 | var db *gorm.DB 16 | var _validator = binding.Validator.Engine().(*validator.Validate) 17 | 18 | func init() { 19 | var err error 20 | dbDsn := config.GetString("db.dsn") 21 | dbType := config.GetString("db.type") 22 | if gin.Mode() != gin.ReleaseMode { 23 | log.Printf("dbType:[%s]\tdbDsn:[%s]\n", dbType, dbDsn) 24 | } 25 | 26 | switch dbType { 27 | case "mysql": 28 | db, err = gorm.Open(mysql.Open(dbDsn), dbConfig()) 29 | case "sqlite": 30 | db, err = gorm.Open(sqlite.Open(dbDsn), dbConfig()) 31 | default: 32 | log.Fatalf("Database not support yet: %s", dbType) 33 | } 34 | if err != nil { 35 | log.Fatalf("Database open failed: %s", err) 36 | } 37 | 38 | if err := Ping(); err != nil { 39 | log.Fatalf("Database ping test failed: %s", err) 40 | } 41 | 42 | if err := RegisterGormCallBack(); err != nil { 43 | log.Fatalf("RegisterGormCallBack: %s", err) 44 | } 45 | } 46 | 47 | func dbConfig() *gorm.Config { 48 | c := gorm.Config{} 49 | if gin.Mode() != gin.ReleaseMode { 50 | c.Logger = logger.Default.LogMode(logger.Info) 51 | } 52 | return &c 53 | } 54 | -------------------------------------------------------------------------------- /models/test_helper.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/icbd/gohighlights/utils" 5 | "math/rand" 6 | "time" 7 | ) 8 | 9 | func FakeUser() *User { 10 | email := utils.GenerateToken(10) + "@gmail.com" 11 | password := "12345678" 12 | u := User{Email: email, Password: password} 13 | u.CalcPasswordHash() 14 | DB().Create(&u) 15 | 16 | return &u 17 | } 18 | 19 | func FakeSelection() *Selection { 20 | rand.Seed(time.Now().Unix()) 21 | texts := make([]string, 1+rand.Intn(3)) 22 | for i, _ := range texts { 23 | texts[i] = utils.GenerateToken(5 + rand.Intn(10)) 24 | } 25 | 26 | startOffset := 0 + rand.Intn(5) 27 | endOffset := 5 + rand.Intn(5) 28 | if len(texts) > 1 { 29 | startOffset = rand.Intn(len(texts[0])) 30 | endOffset = rand.Intn(len(texts[len(texts)-1])) 31 | } 32 | 33 | return &Selection{ 34 | Texts: texts, 35 | StartOffset: startOffset, 36 | EndOffset: endOffset, 37 | } 38 | } 39 | 40 | func FakeMark(userID uint) *Mark { 41 | mark := &Mark{ 42 | UserID: userID, 43 | URL: "http://localhost/", 44 | Tag: "blue", 45 | HashKey: utils.GenerateToken(16), 46 | Selection: *FakeSelection(), 47 | } 48 | DB().Create(mark) 49 | 50 | return mark 51 | } 52 | 53 | func FakeComment(userID uint, markID uint) *Comment { 54 | comment := &Comment{ 55 | UserID: userID, 56 | MarkID: markID, 57 | Content: utils.GenerateToken(10), 58 | } 59 | DB().Create(comment) 60 | return comment 61 | } 62 | -------------------------------------------------------------------------------- /controllers/api/v1/users_controller.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "errors" 5 | "github.com/gin-gonic/gin" 6 | "github.com/icbd/gohighlights/controllers/api" 7 | "github.com/icbd/gohighlights/models" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | // GET /api/v1/users/:id 12 | func UsersShow(c *gin.Context) { 13 | resp := api.New(c) 14 | u := api.CurrentUser(c) 15 | if u == nil { 16 | resp.NotFoundErr() 17 | } else { 18 | resp.OK(u) 19 | } 20 | } 21 | 22 | /** 23 | Register or Login 24 | 25 | POST /api/v1/users 26 | { 27 | email: "", 28 | password: "" 29 | } 30 | */ 31 | func UsersCreate(c *gin.Context) { 32 | resp := api.New(c) 33 | vo := models.SessionVO{} 34 | if err := c.ShouldBind(&vo); err != nil { 35 | resp.ParametersErr(err) 36 | return 37 | } 38 | 39 | u, err := models.UserFindByEmail(vo.Email) 40 | if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { 41 | resp.InternalErr() 42 | return 43 | } 44 | 45 | if errors.Is(err, gorm.ErrRecordNotFound) { 46 | // register new user 47 | u, err = models.UserCreate(vo.Email, vo.Password) 48 | if err != nil { 49 | resp.ParametersErr(err) 50 | return 51 | } 52 | } else { 53 | // check use password 54 | u.Password = vo.Password 55 | if !u.ValidPassword() { 56 | resp.UnauthorizedErr() 57 | return 58 | } 59 | } 60 | 61 | // login 62 | if s, err := u.GenerateSession(); err != nil { 63 | resp.ParametersErr(err) 64 | } else { 65 | resp.Created(s) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | on: [ push, pull_request ] 5 | jobs: 6 | build-and-test: 7 | runs-on: ubuntu-latest 8 | env: 9 | GIN_MODE: test 10 | CONF_LOC: ./gohighlights/config.yaml 11 | steps: 12 | - name: Setup MySQL 13 | uses: mirromutth/mysql-action@v1.1 14 | with: 15 | host port: 3306 16 | container port: 3306 17 | character set server: utf8mb4 18 | collation server: utf8mb4_general_ci 19 | mysql version: 8.0 20 | mysql root password: rootpassword 21 | mysql database: gohighlights_test 22 | mysql user: root 23 | mysql password: rootpassword 24 | 25 | - uses: miyataka/elasticsearch-github-actions@1 26 | with: 27 | stack-version: '7.10.0' 28 | plugins: 'https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.10.0/elasticsearch-analysis-ik-7.10.0.zip' 29 | 30 | - name: Checkout code 31 | uses: actions/checkout@v2 32 | 33 | - name: Install Go 34 | uses: actions/setup-go@v1 35 | with: 36 | go-version: 1.15 37 | 38 | - name: Debug 39 | run: ls -AlFh; pwd; which go; go env; echo $GIN_MODE; free -h; cat /proc/cpuinfo; 40 | 41 | - name: Migrate 42 | run: go run ./bin/migrate_cmd.go -db=migrate 43 | 44 | - name: Run Test 45 | run: go test ./... -cover 46 | -------------------------------------------------------------------------------- /indices/init.go: -------------------------------------------------------------------------------- 1 | package indices 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/gin-gonic/gin" 7 | "github.com/icbd/gohighlights/config" 8 | "github.com/olivere/elastic/v7" 9 | "log" 10 | ) 11 | 12 | var client *elastic.Client 13 | var Enable bool // if not, jump ES processing 14 | var indexNamePrefix string 15 | var NotEnabledError = fmt.Errorf("es is not enabled yet") 16 | 17 | func init() { 18 | indexNamePrefix = config.GetString("es.prefix") 19 | Enable = config.GetBool("es.enable") 20 | if !Enable { 21 | return 22 | } 23 | 24 | var err error 25 | esConfig := []elastic.ClientOptionFunc{elastic.SetURL(config.GetString("es.url"))} 26 | if gin.Mode() != gin.ReleaseMode { 27 | esConfig = append(esConfig, elastic.SetTraceLog(log.New(log.Writer(), "\n", 0))) 28 | } 29 | client, err = elastic.NewClient(esConfig...) 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | } 34 | 35 | func Client() *elastic.Client { 36 | return client 37 | } 38 | 39 | func IndexName(name string) string { 40 | return indexNamePrefix + name 41 | } 42 | 43 | type SetupMethod func() error 44 | 45 | func setupMethods() []SetupMethod { 46 | return []SetupMethod{ 47 | SetupMarkIndex, 48 | } 49 | } 50 | 51 | // Ping and init indices 52 | func Ping() { 53 | if !Enable { 54 | return 55 | } 56 | 57 | info, _, err := Client().Ping(config.GetString("es.url")).Do(context.Background()) 58 | 59 | if err != nil { 60 | log.Fatal(err) 61 | } else { 62 | log.Printf("Elasticsearch Ping: %#v\n", info) 63 | } 64 | 65 | for _, f := range setupMethods() { 66 | if err := f(); err != nil { 67 | log.Fatal(err) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /models/comment.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Comment struct { 4 | BaseModel 5 | UserID uint `gorm:"not null" json:"-" binding:"required"` 6 | User *User `json:"user,omitempty"` 7 | MarkID uint `gorm:"index;not null" json:"mark_id" binding:"required"` 8 | Content string `gorm:"type:text" json:"content" binding:"required"` 9 | } 10 | 11 | type CommentVO struct { 12 | Content string `json:"content" binding:"required"` 13 | } 14 | 15 | func CommentFind(commentID uint) (comment *Comment, err error) { 16 | comment = &Comment{} 17 | err = DB().First(&comment, commentID).Error 18 | return comment, err 19 | } 20 | 21 | func CommentFindByMarkID(userID uint, markID uint) (comment *Comment, err error) { 22 | comment = &Comment{} 23 | err = DB().Where("user_id = ? AND mark_id = ?", userID, markID).First(&comment).Error 24 | return comment, err 25 | } 26 | 27 | func CommentCreate(userID uint, markID uint, content string) (comment *Comment, err error) { 28 | comment = &Comment{ 29 | UserID: userID, 30 | MarkID: markID, 31 | Content: content, 32 | } 33 | err = DB().Create(comment).Error 34 | return comment, err 35 | } 36 | 37 | func CommentUpdate(userID uint, markID uint, content string) (comment *Comment, err error) { 38 | comment, err = CommentFindByMarkID(userID, markID) 39 | if err != nil { 40 | return nil, err 41 | } 42 | err = DB().Model(&comment).Update("content", content).Error 43 | return comment, err 44 | } 45 | 46 | func CommentDestroy(userID uint, markID uint) (comment *Comment, err error) { 47 | comment, err = CommentFindByMarkID(userID, markID) 48 | if err != nil { 49 | return nil, err 50 | } 51 | err = DB().Delete(comment).Error 52 | return comment, err 53 | } 54 | -------------------------------------------------------------------------------- /controllers/middleware/current_user_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/gin-gonic/gin" 6 | "github.com/icbd/gohighlights/models" 7 | "github.com/spf13/cast" 8 | "github.com/stretchr/testify/assert" 9 | "net/http" 10 | "net/http/httptest" 11 | "testing" 12 | ) 13 | 14 | func init() { 15 | gin.SetMode(gin.TestMode) 16 | } 17 | 18 | var user *models.User 19 | var session *models.Session 20 | 21 | func init() { 22 | user = models.FakeUser() 23 | session, _ = user.GenerateSession() 24 | } 25 | 26 | func TestCurrentUserMiddleware(t *testing.T) { 27 | assert := assert.New(t) 28 | 29 | routePath := "/CurrentUserMiddleware" 30 | r := fakeRouter(routePath) 31 | 32 | w := httptest.NewRecorder() 33 | req, _ := http.NewRequest("GET", routePath, nil) 34 | req.Header.Set("Authorization", "Bearer "+session.Token) 35 | r.ServeHTTP(w, req) 36 | assert.Equal(http.StatusOK, w.Code) 37 | 38 | resp := gin.H{} 39 | json.Unmarshal(w.Body.Bytes(), &resp) 40 | assert.Equal(cast.ToUint(resp["id"]), user.ID) 41 | } 42 | 43 | func TestCurrentUserMiddleware_Unauthorized(t *testing.T) { 44 | assert := assert.New(t) 45 | 46 | routePath := "/CurrentUserMiddleware" 47 | r := fakeRouter(routePath) 48 | 49 | w := httptest.NewRecorder() 50 | req, _ := http.NewRequest("GET", routePath, nil) 51 | req.Header.Set("Authorization", "Bearer InvalidToken") 52 | r.ServeHTTP(w, req) 53 | assert.Equal(http.StatusUnauthorized, w.Code) 54 | } 55 | 56 | func fakeRouter(routePath string) *gin.Engine { 57 | r := gin.New() 58 | r.Use(CurrentUserMiddleware) 59 | r.GET(routePath, func(c *gin.Context) { 60 | u, _ := c.Get(CurrentUser) 61 | c.JSON(200, u) 62 | }) 63 | return r 64 | } 65 | -------------------------------------------------------------------------------- /indices/mark_index_test.go: -------------------------------------------------------------------------------- 1 | package indices 2 | 3 | import ( 4 | "context" 5 | "github.com/gin-gonic/gin" 6 | "github.com/icbd/gohighlights/models" 7 | "github.com/olivere/elastic/v7" 8 | "github.com/spf13/cast" 9 | "github.com/stretchr/testify/assert" 10 | "strings" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func init() { 16 | gin.SetMode(gin.TestMode) 17 | } 18 | 19 | func cleanAllIndices() { 20 | indices, _ := elastic.NewCatIndicesService(Client()).Do(context.Background()) 21 | for _, index := range indices { 22 | if strings.HasPrefix(index.Index, indexNamePrefix) { 23 | Client().DeleteIndex(index.Index).Do(context.Background()) 24 | } 25 | } 26 | } 27 | 28 | func indexTotalCount(indexName string) int64 { 29 | count, _ := Client().Count(indexName).Do(context.Background()) 30 | return count 31 | } 32 | 33 | func TestSetupMarkIndex(t *testing.T) { 34 | assert := assert.New(t) 35 | cleanAllIndices() 36 | Client().DeleteIndex(IndexName(MarkIndexName)).Do(context.Background()) 37 | 38 | if err := SetupMarkIndex(); err != nil { 39 | assert.Error(err) 40 | } 41 | exist, _ := Client().IndexExists(IndexName(MarkIndexName)).Do(context.Background()) 42 | assert.True(exist) 43 | } 44 | 45 | func TestMarkIndex_Fresh(t *testing.T) { 46 | assert := assert.New(t) 47 | cleanAllIndices() 48 | 49 | mark := models.FakeMark(1) 50 | resp, err := NewMarkIndex(mark).Fresh() 51 | assert.Nil(err) 52 | assert.Equal(cast.ToString(mark.ID), resp.Id) 53 | 54 | time.Sleep(time.Second) 55 | assert.Equal(cast.ToInt64(1), indexTotalCount(IndexName(MarkIndexName))) 56 | } 57 | 58 | func TestMarkIndex_MarkIndexDelete(t *testing.T) { 59 | assert := assert.New(t) 60 | cleanAllIndices() 61 | 62 | mark := models.FakeMark(1) 63 | NewMarkIndex(mark).Fresh() 64 | _, err := MarkIndexDelete(mark.ID) 65 | assert.Nil(err) 66 | 67 | time.Sleep(time.Second) 68 | assert.Equal(cast.ToInt64(0), indexTotalCount(IndexName(MarkIndexName))) 69 | } 70 | -------------------------------------------------------------------------------- /models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "github.com/icbd/gohighlights/utils" 6 | "golang.org/x/crypto/bcrypt" 7 | "log" 8 | "time" 9 | ) 10 | 11 | type User struct { 12 | BaseModel 13 | Status string `json:"status"` 14 | Email string `json:"email" binding:"required"` 15 | Avatar string `json:"avatar"` 16 | PasswordHash string `json:"-"` 17 | Password string `json:"-" gorm:"-"` 18 | } 19 | 20 | type UserStatus string 21 | 22 | func (s UserStatus) String() string { 23 | return string(s) 24 | } 25 | 26 | const ( 27 | ActivatedStatus UserStatus = "ActivatedStatus" 28 | InactiveStatus UserStatus = "InactiveStatus" 29 | ) 30 | 31 | var UserStatuses = map[string]UserStatus{ 32 | "ActivatedStatus": ActivatedStatus, 33 | "InactiveStatus": InactiveStatus, 34 | } 35 | 36 | func (u *User) CalcPasswordHash() error { 37 | if len(u.Password) < 6 { 38 | return fmt.Errorf("password too short") 39 | } 40 | 41 | hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | 46 | u.PasswordHash = string(hash) 47 | return nil 48 | } 49 | 50 | func (u *User) ValidPassword() bool { 51 | return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(u.Password)) == nil 52 | } 53 | 54 | func UserFindByEmail(email string) (u *User, err error) { 55 | u = &User{} 56 | err = DB().Where("email = ?", email).First(u).Error 57 | return 58 | } 59 | 60 | func UserCreate(email string, password string) (u *User, err error) { 61 | u = &User{Email: email, Password: password} 62 | if err := u.CalcPasswordHash(); err != nil { 63 | return nil, err 64 | } 65 | err = DB().Create(u).Error 66 | return u, err 67 | } 68 | 69 | func (u *User) GenerateSession() (s *Session, err error) { 70 | s = &Session{ 71 | Token: utils.GenerateToken(16), 72 | ExpiredAt: time.Now().AddDate(0, 1, 0), 73 | UserID: u.ID} 74 | err = DB().Create(&s).Error 75 | s.User = u 76 | return s, nil 77 | } 78 | -------------------------------------------------------------------------------- /controllers/api/v1/users_controller_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "github.com/gin-gonic/gin" 7 | "github.com/icbd/gohighlights/controllers/middleware" 8 | "github.com/icbd/gohighlights/models" 9 | "github.com/spf13/cast" 10 | "github.com/stretchr/testify/assert" 11 | "net/http" 12 | "net/http/httptest" 13 | "testing" 14 | ) 15 | 16 | func init() { 17 | gin.SetMode(gin.TestMode) 18 | } 19 | 20 | func TestUsersCreate(t *testing.T) { 21 | assert := assert.New(t) 22 | 23 | u := models.FakeUser() 24 | body, _ := json.Marshal(gin.H{"email": u.Email, "password": u.Password}) 25 | models.DB().Unscoped().Delete(&u) 26 | 27 | w := httptest.NewRecorder() 28 | ctx, _ := gin.CreateTestContext(w) 29 | ctx.Request = httptest.NewRequest("POST", "/api/v1/users", bytes.NewReader(body)) 30 | ctx.Request.Header.Set("Content-Type", "application/json") 31 | 32 | UsersCreate(ctx) 33 | assert.Equal(http.StatusCreated, w.Code) 34 | var resp map[string]interface{} 35 | _ = json.Unmarshal(w.Body.Bytes(), &resp) 36 | assert.True(cast.ToUint(resp["id"]) != 0) 37 | } 38 | 39 | func TestUsersCreate_BadRequest(t *testing.T) { 40 | assert := assert.New(t) 41 | 42 | u := models.FakeUser() // already created by u.Email 43 | body, _ := json.Marshal(gin.H{"email": u.Email, "password": u.Password}) 44 | 45 | w := httptest.NewRecorder() 46 | ctx, _ := gin.CreateTestContext(w) 47 | ctx.Request = httptest.NewRequest("POST", "/api/v1/users", bytes.NewReader(body)) 48 | 49 | UsersCreate(ctx) 50 | assert.Equal(http.StatusBadRequest, w.Code) 51 | } 52 | 53 | // Controller test won't go through the middleware 54 | func TestUsersShow_NotFound(t *testing.T) { 55 | assert := assert.New(t) 56 | 57 | u := models.FakeUser() 58 | 59 | w := httptest.NewRecorder() 60 | ctx, _ := gin.CreateTestContext(w) 61 | ctx.Request = httptest.NewRequest("GET", "/api/v1/users/"+cast.ToString(u.ID), nil) 62 | ctx.Set(middleware.CurrentUser, u) 63 | 64 | UsersShow(ctx) 65 | assert.Equal(http.StatusOK, w.Code) 66 | } 67 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # GoHighlights 2 | 3 | ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/icbd/gohighlights/CI) 4 | ![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/icbd/gohighlights) 5 | 6 | This is GoHighlights's server project, the frontend project is [https://github.com/icbd/gohighlights_ext](https://github.com/icbd/gohighlights_ext) . 7 | 8 | ## Features 9 | 10 | ### Ready 11 | 12 | - [x] Add Highlight 13 | - [x] Change Color 14 | - [x] Remove Highlight 15 | - [x] Replay Highlight 16 | 17 | ### Todo 18 | 19 | - [ ] History Dashboard 20 | - [ ] User-friendly Personalization Setting 21 | - [ ] Web Timer 22 | - [ ] Reading Report 23 | - [ ] Read Later Box 24 | 25 | ![https://github.com/icbd/gohighlights_ext/blob/master/demo.png](https://github.com/icbd/gohighlights_ext/blob/master/demo.png) 26 | 27 | You can try the online version of the chrome store: 28 | 29 | > [https://chrome.google.com/webstore/detail/go-highlights/homlcfpinafhealhlmjkmdjdejppmmlk](https://chrome.google.com/webstore/detail/go-highlights/homlcfpinafhealhlmjkmdjdejppmmlk) 30 | 31 | ## Config 32 | 33 | ### ENV 34 | 35 | ENV Tag|Description|Default 36 | ---|---|--- 37 | GIN_MODE | gin mode (debug/test/release) | `debug` 38 | DB_TYPE | database type (mysql/sqlite) | `sqlite` 39 | DB_DSN | database data source name | `root:password@tcp(127.0.0.1:3306)/dbname` 40 | CONF_LOC | config file location | `./config.yaml` 41 | ES_URL | elasticsearch url | `http://localhost:9200` 42 | 43 | ### CMD 44 | 45 | 0. Edit Config File 46 | 47 | ```shell script 48 | vi config.yaml 49 | ``` 50 | 51 | If you are using MySQL, please create the database manually. 52 | 53 | 1. Migration 54 | 55 | ```shell script 56 | GIN_MODE=debug go run ./bin/migrate_cmd.go -db=migrate 57 | ``` 58 | 59 | Also see [https://github.com/icbd/gorm-migration](https://github.com/icbd/gorm-migration) . 60 | 61 | 2. Run Server 62 | 63 | ```shell script 64 | GIN_MODE=debug go run ./main.go 65 | ``` 66 | 67 | ## Run test 68 | 69 | including sub-packages 70 | 71 | ```shell script 72 | GIN_MODE=test go run ./bin/migrate_cmd.go -db=migrate 73 | GIN_MODE=test go test ./... -v 74 | ``` 75 | 76 | ## License 77 | 78 | MIT, see [LICENSE](LICENSE) -------------------------------------------------------------------------------- /config/init.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "github.com/gin-gonic/gin" 6 | "github.com/icbd/gohighlights/utils" 7 | "github.com/spf13/viper" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "text/template" 12 | ) 13 | 14 | const ( 15 | EnvKeyConfigLocation = "CONF_LOC" 16 | DefaultConfigFile = "./config.yaml" 17 | ) 18 | 19 | func init() { 20 | parseConfigFile(configFileLocation()) 21 | } 22 | 23 | func configFileLocation() string { 24 | loc, ok := os.LookupEnv(EnvKeyConfigLocation) 25 | if !ok { 26 | loc = DefaultConfigFile 27 | } 28 | 29 | if !filepath.IsAbs(loc) { 30 | loc = filepath.Join(utils.RootPath(), loc) 31 | } 32 | return loc 33 | } 34 | 35 | func parseConfigFile(file string) { 36 | var configBuffer bytes.Buffer 37 | //tmpl := template.Must(template.New(filepath.Base(file)).Funcs(template.FuncMap{"ENV": func(k string) string { return os.Getenv(k) }}).ParseFiles(file)) 38 | tmpl := template.New(filepath.Base(file)) 39 | tmpl.Funcs(template.FuncMap{"ENV": tmplENV}) 40 | template.Must(tmpl.ParseFiles(file)) 41 | if err := tmpl.Execute(&configBuffer, nil); err != nil { 42 | log.Fatal(err) 43 | } 44 | viper.SetConfigType("yaml") 45 | if err := viper.ReadConfig(&configBuffer); err != nil { 46 | log.Fatal(err) 47 | } 48 | } 49 | 50 | // tmplENV template function 51 | // EMV "v1" # return os.Getenv("v1") 52 | // EMV "v1" "v2" # if os.Getenv("v1") blank, return "v2" 53 | // ENV # invalid params return "" 54 | func tmplENV(keys ...string) (value string) { 55 | switch len(keys) { 56 | case 1: 57 | value = os.Getenv(keys[0]) 58 | case 2: 59 | value = os.Getenv(keys[0]) 60 | if value == "" { 61 | value = keys[1] 62 | } 63 | } 64 | return 65 | } 66 | 67 | func Get(key string) interface{} { 68 | return viper.Get(configKey(key)) 69 | } 70 | 71 | func GetString(key string) string { 72 | return viper.GetString(configKey(key)) 73 | } 74 | 75 | func GetInt(key string) int { 76 | return viper.GetInt(configKey(key)) 77 | } 78 | 79 | func GetBool(key string) bool { 80 | return viper.GetBool(configKey(key)) 81 | } 82 | 83 | func Set(key string, value interface{}) { 84 | viper.Set(configKey(key), value) 85 | } 86 | 87 | // configKey trans key to local key 88 | func configKey(key string) string { 89 | return gin.Mode() + "." + key 90 | } 91 | -------------------------------------------------------------------------------- /controllers/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "github.com/gin-gonic/gin" 6 | "github.com/go-playground/validator/v10" 7 | "github.com/icbd/gohighlights/controllers/middleware" 8 | "github.com/icbd/gohighlights/errs" 9 | "github.com/icbd/gohighlights/models" 10 | "gorm.io/gorm" 11 | "net/http" 12 | ) 13 | 14 | type API struct { 15 | C *gin.Context 16 | } 17 | 18 | func New(c *gin.Context) *API { 19 | return &API{C: c} 20 | } 21 | 22 | func (a *API) OK(payload interface{}) { 23 | a.C.JSON(http.StatusOK, payload) 24 | } 25 | 26 | func (a *API) Created(payload interface{}) { 27 | a.C.JSON(http.StatusCreated, payload) 28 | } 29 | 30 | func (a *API) NoContent() { 31 | a.C.Writer.WriteHeader(http.StatusNoContent) 32 | a.C.Writer.WriteHeaderNow() 33 | } 34 | 35 | func (a *API) InternalErr() { 36 | a.C.Writer.WriteHeader(http.StatusInternalServerError) 37 | a.C.Writer.WriteHeaderNow() 38 | } 39 | 40 | func (a *API) UnauthorizedErr() { 41 | a.C.Writer.WriteHeader(http.StatusUnauthorized) 42 | a.C.Writer.WriteHeaderNow() 43 | } 44 | 45 | func (a *API) ForbiddenErr() { 46 | a.C.Writer.WriteHeader(http.StatusForbidden) 47 | a.C.Writer.WriteHeaderNow() 48 | } 49 | 50 | func (a *API) ParametersErr(err error) { 51 | if vErr, ok := err.(validator.ValidationErrors); ok { 52 | msg := make([]string, len(vErr)) 53 | for i, e := range vErr { 54 | msg[i] = e.Field() + "::" + e.Tag() 55 | } 56 | a.C.JSON(http.StatusBadRequest, errs.E{C: errs.Validation, M: msg}) 57 | return 58 | } 59 | 60 | if errors.Is(err, gorm.ErrRecordNotFound) { 61 | a.C.Writer.WriteHeader(http.StatusNotFound) 62 | a.C.Writer.WriteHeaderNow() 63 | } else { 64 | a.C.JSON(http.StatusBadRequest, errs.E{C: errs.Parameters, M: []string{err.Error()}}) 65 | } 66 | } 67 | 68 | func (a *API) NotFoundErr() { 69 | a.C.Writer.WriteHeader(http.StatusNotFound) 70 | a.C.Writer.WriteHeaderNow() 71 | } 72 | 73 | func (a *API) Err(httpCode int, businessCode int, err error) { 74 | a.C.JSON(httpCode, errs.E{C: businessCode, M: []string{err.Error()}}) 75 | } 76 | 77 | // CurrentUser Always use this method after CurrentUserMiddleware, 78 | // so you will get a valid user pointer. 79 | func CurrentUser(c *gin.Context) *models.User { 80 | if v, ok := c.Get(middleware.CurrentUser); ok { 81 | if u, ok := v.(*models.User); ok { 82 | return u 83 | } 84 | } 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /controllers/api/v1/comments_controller_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "github.com/gin-gonic/gin" 7 | "github.com/icbd/gohighlights/controllers/middleware" 8 | "github.com/icbd/gohighlights/models" 9 | "github.com/icbd/gohighlights/utils" 10 | "github.com/spf13/cast" 11 | "github.com/stretchr/testify/assert" 12 | "net/http" 13 | "net/http/httptest" 14 | "testing" 15 | ) 16 | 17 | func TestCommentsCreate(t *testing.T) { 18 | assert := assert.New(t) 19 | 20 | oldMark := models.FakeMark(currentSession.UserID) 21 | 22 | params := gin.H{"content": utils.GenerateToken(10)} 23 | body, _ := json.Marshal(params) 24 | 25 | w := httptest.NewRecorder() 26 | ctx, _ := gin.CreateTestContext(w) 27 | ctx.Set(middleware.CurrentUser, currentSession.User) 28 | ctx.Request = httptest.NewRequest("POST", "/api/v1/marks/:hash_key/comment", bytes.NewReader(body)) 29 | ctx.Params = gin.Params{gin.Param{Key: "hash_key", Value: oldMark.HashKey}} 30 | CommentsCreate(ctx) 31 | 32 | assert.Equal(http.StatusCreated, w.Code) 33 | 34 | var resp map[string]interface{} 35 | _ = json.Unmarshal(w.Body.Bytes(), &resp) 36 | assert.True(cast.ToUint(resp["id"]) != 0) 37 | } 38 | 39 | func TestCommentsUpdate(t *testing.T) { 40 | assert := assert.New(t) 41 | 42 | oldMark := models.FakeMark(currentSession.UserID) 43 | oldComment := models.FakeComment(currentSession.UserID, oldMark.ID) 44 | 45 | params := gin.H{"content": utils.GenerateToken(10)} 46 | body, _ := json.Marshal(params) 47 | 48 | w := httptest.NewRecorder() 49 | ctx, _ := gin.CreateTestContext(w) 50 | ctx.Set(middleware.CurrentUser, currentSession.User) 51 | ctx.Request = httptest.NewRequest("PATCH", "/api/vi/marks/:hash_key/comment", bytes.NewReader(body)) 52 | ctx.Params = gin.Params{gin.Param{Key: "hash_key", Value: oldMark.HashKey}} 53 | CommentsUpdate(ctx) 54 | 55 | assert.Equal(http.StatusOK, w.Code) 56 | 57 | var resp map[string]interface{} 58 | _ = json.Unmarshal(w.Body.Bytes(), &resp) 59 | assert.Equal(params["content"], cast.ToString(resp["content"])) 60 | assert.Equal(oldComment.ID, cast.ToUint(resp["id"])) 61 | } 62 | 63 | func TestCommentsDestroy(t *testing.T) { 64 | assert := assert.New(t) 65 | 66 | oldMark := models.FakeMark(currentSession.UserID) 67 | models.FakeComment(currentSession.UserID, oldMark.ID) 68 | 69 | w := httptest.NewRecorder() 70 | ctx, _ := gin.CreateTestContext(w) 71 | ctx.Set(middleware.CurrentUser, currentSession.User) 72 | ctx.Request = httptest.NewRequest("DELETE", "/api/vi/marks/:hash_key/comment", nil) 73 | ctx.Params = gin.Params{gin.Param{Key: "hash_key", Value: oldMark.HashKey}} 74 | CommentsDestroy(ctx) 75 | 76 | assert.Equal(http.StatusNoContent, w.Code) 77 | } 78 | -------------------------------------------------------------------------------- /controllers/api/v1/marks_controller.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/icbd/gohighlights/controllers/api" 6 | "github.com/icbd/gohighlights/indices" 7 | "github.com/icbd/gohighlights/models" 8 | "net/url" 9 | ) 10 | 11 | /** 12 | POST /api/v1/marks 13 | { 14 | url: "url without query params and anchor", 15 | tag: "color or custom tag string", 16 | hash_key: "uuid generate by frontend", 17 | selection: "" 18 | } 19 | */ 20 | func MarksCreate(c *gin.Context) { 21 | resp := api.New(c) 22 | u := api.CurrentUser(c) 23 | 24 | vo := models.MarkCreateVO{} 25 | if err := c.BindJSON(&vo); err != nil { 26 | resp.ParametersErr(err) 27 | return 28 | } 29 | 30 | if mark, err := models.MarkCreate(u.ID, &vo); err != nil { 31 | resp.ParametersErr(err) 32 | } else { 33 | indices.NewMarkIndex(mark).Fresh() 34 | resp.Created(mark) 35 | } 36 | } 37 | 38 | /** 39 | PATCH /api/v1/marks/:hash_key 40 | { 41 | tag: "new tag value" 42 | } 43 | */ 44 | func MarksUpdate(c *gin.Context) { 45 | resp := api.New(c) 46 | u := api.CurrentUser(c) 47 | 48 | hashKey := c.Param("hash_key") 49 | vo := models.MarkUpdateVO{} 50 | if err := c.BindJSON(&vo); err != nil { 51 | resp.ParametersErr(err) 52 | return 53 | } 54 | 55 | if mark, err := models.MarkUpdate(u.ID, hashKey, &vo); err != nil { 56 | resp.ParametersErr(err) 57 | } else { 58 | indices.NewMarkIndex(mark).Fresh() 59 | resp.OK(mark) 60 | } 61 | } 62 | 63 | /** 64 | DELETE /api/v1/marks/:hash_key 65 | */ 66 | func MarksDestroy(c *gin.Context) { 67 | resp := api.New(c) 68 | u := api.CurrentUser(c) 69 | 70 | if m, err := models.MarkDestroy(u.ID, c.Param("hash_key")); err != nil { 71 | resp.ParametersErr(err) 72 | } else { 73 | indices.MarkIndexDelete(m.ID) 74 | resp.NoContent() 75 | } 76 | } 77 | 78 | /** 79 | GET /api/v1/marks/query?url=encodeURIComponent(btoa(url)) 80 | */ 81 | func MarksQuery(c *gin.Context) { 82 | resp := api.New(c) 83 | u := api.CurrentUser(c) 84 | marks := models.MarkQuery(u.ID, c.Query("url")) 85 | resp.OK(marks) 86 | } 87 | 88 | /** 89 | GET /api/v1/marks/search?q=xxx 90 | */ 91 | func MarksSearch(c *gin.Context) { 92 | resp := api.New(c) 93 | u := api.CurrentUser(c) 94 | if text, err := url.QueryUnescape(c.Query("q")); err != nil { 95 | resp.ParametersErr(err) 96 | return 97 | } else { 98 | resp.OK(indices.Query(u, text)) 99 | } 100 | } 101 | 102 | /** 103 | GET /api/v1/marks?page=1&size=10 104 | */ 105 | func MarksIndex(c *gin.Context) { 106 | resp := api.New(c) 107 | u := api.CurrentUser(c) 108 | 109 | pagination := models.Pagination 110 | if err := c.Bind(&pagination); err != nil { 111 | resp.ParametersErr(err) 112 | return 113 | } 114 | 115 | resp.OK(gin.H{ 116 | "total": models.MarkTotal(u.ID), 117 | "items": models.MarkList(u.ID, pagination), 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /controllers/api/v1/comments_controller.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/icbd/gohighlights/controllers/api" 6 | "github.com/icbd/gohighlights/indices" 7 | "github.com/icbd/gohighlights/models" 8 | ) 9 | 10 | /** 11 | POST /marks/:hash_key/comment 12 | { 13 | content: "comment content", 14 | } 15 | */ 16 | func CommentsCreate(c *gin.Context) { 17 | resp := api.New(c) 18 | u := api.CurrentUser(c) 19 | 20 | vo := models.CommentVO{} 21 | if err := c.BindJSON(&vo); err != nil { 22 | resp.ParametersErr(err) 23 | return 24 | } 25 | 26 | mark, err := models.MarkFindByHashKey(u.ID, c.Param("hash_key")) 27 | if err != nil { 28 | resp.ParametersErr(err) 29 | return 30 | } 31 | 32 | if comment, err := models.CommentCreate(u.ID, mark.ID, vo.Content); err != nil { 33 | resp.ParametersErr(err) 34 | } else { 35 | resp.Created(comment) 36 | } 37 | } 38 | 39 | /** 40 | PATCH /marks/:hash_key/comment 41 | { 42 | content: "new comment content", 43 | } 44 | */ 45 | func CommentsUpdate(c *gin.Context) { 46 | resp := api.New(c) 47 | u := api.CurrentUser(c) 48 | 49 | vo := models.CommentVO{} 50 | if err := c.BindJSON(&vo); err != nil { 51 | resp.ParametersErr(err) 52 | return 53 | } 54 | 55 | mark, err := models.MarkFindByHashKey(u.ID, c.Param("hash_key")) 56 | if err != nil { 57 | resp.ParametersErr(err) 58 | return 59 | } 60 | 61 | if comment, err := models.CommentUpdate(u.ID, mark.ID, vo.Content); err != nil { 62 | resp.ParametersErr(err) 63 | } else { 64 | resp.OK(comment) 65 | } 66 | } 67 | 68 | /** 69 | PUT /marks/:hash_key/comment 70 | { 71 | content: "new or update comment content", 72 | } 73 | */ 74 | func CommentsPut(c *gin.Context) { 75 | resp := api.New(c) 76 | u := api.CurrentUser(c) 77 | 78 | vo := models.CommentVO{} 79 | if err := c.BindJSON(&vo); err != nil { 80 | resp.ParametersErr(err) 81 | return 82 | } 83 | 84 | mark, err := models.MarkFindByHashKey(u.ID, c.Param("hash_key")) 85 | if err != nil { 86 | resp.ParametersErr(err) 87 | return 88 | } 89 | 90 | var comment *models.Comment 91 | if mark.Comment == nil { 92 | // create 93 | if vo.Content != "" { 94 | comment, err = models.CommentCreate(u.ID, mark.ID, vo.Content) 95 | } 96 | } else { 97 | // update 98 | comment, err = models.CommentUpdate(u.ID, mark.ID, vo.Content) 99 | } 100 | if err != nil { 101 | resp.ParametersErr(err) 102 | } else { 103 | indices.NewCommentIndex(comment).Update() 104 | resp.OK(comment) 105 | } 106 | } 107 | 108 | /** 109 | DELETE /marks/:hash_key/comment 110 | */ 111 | func CommentsDestroy(c *gin.Context) { 112 | resp := api.New(c) 113 | u := api.CurrentUser(c) 114 | 115 | mark, err := models.MarkFindByHashKey(u.ID, c.Param("hash_key")) 116 | if err != nil { 117 | resp.ParametersErr(err) 118 | return 119 | } 120 | 121 | if _, err := models.CommentDestroy(u.ID, mark.ID); err != nil { 122 | resp.ParametersErr(err) 123 | } else { 124 | indices.MarkIndexDelete(mark.ID) 125 | resp.NoContent() 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /models/soft_delete.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "github.com/spf13/cast" 7 | gorm "gorm.io/gorm" 8 | "gorm.io/gorm/schema" 9 | "reflect" 10 | 11 | "gorm.io/gorm/clause" 12 | ) 13 | 14 | /** 15 | Code is referenced https://github.com/go-gorm/gorm/blob/master/soft_delete.go 16 | */ 17 | 18 | type DeletedAt int64 19 | 20 | // Scan implements the Scanner interface. 21 | func (n *DeletedAt) Scan(value interface{}) error { 22 | *n = DeletedAt(cast.ToInt64(value)) 23 | return nil 24 | } 25 | 26 | // Value implements the driver Valuer interface. 27 | func (n DeletedAt) Value() (driver.Value, error) { 28 | return int64(n), nil 29 | } 30 | 31 | func (n DeletedAt) MarshalJSON() ([]byte, error) { 32 | return json.Marshal(n) 33 | } 34 | 35 | func (n *DeletedAt) UnmarshalJSON(b []byte) error { 36 | err := json.Unmarshal(b, &n) 37 | return err 38 | } 39 | 40 | func (DeletedAt) QueryClauses(f *schema.Field) []clause.Interface { 41 | return []clause.Interface{SoftDeleteQueryClause{Field: f}} 42 | } 43 | 44 | type SoftDeleteQueryClause struct { 45 | Field *schema.Field 46 | } 47 | 48 | func (sd SoftDeleteQueryClause) Name() string { 49 | return "" 50 | } 51 | 52 | func (sd SoftDeleteQueryClause) Build(clause.Builder) { 53 | } 54 | 55 | func (sd SoftDeleteQueryClause) MergeClause(*clause.Clause) { 56 | } 57 | 58 | func (sd SoftDeleteQueryClause) ModifyStatement(stmt *gorm.Statement) { 59 | if _, ok := stmt.Clauses["soft_delete_enabled"]; !ok { 60 | if c, ok := stmt.Clauses["WHERE"]; ok { 61 | if where, ok := c.Expression.(clause.Where); ok && len(where.Exprs) > 1 { 62 | for _, expr := range where.Exprs { 63 | if orCond, ok := expr.(clause.OrConditions); ok && len(orCond.Exprs) == 1 { 64 | where.Exprs = []clause.Expression{clause.And(where.Exprs...)} 65 | c.Expression = where 66 | stmt.Clauses["WHERE"] = c 67 | break 68 | } 69 | } 70 | } 71 | } 72 | 73 | stmt.AddClause(clause.Where{Exprs: []clause.Expression{ 74 | clause.Eq{Column: clause.Column{Table: clause.CurrentTable, Name: sd.Field.DBName}, Value: 0}, 75 | }}) 76 | stmt.Clauses["soft_delete_enabled"] = clause.Clause{} 77 | } 78 | } 79 | 80 | func (DeletedAt) DeleteClauses(f *schema.Field) []clause.Interface { 81 | return []clause.Interface{SoftDeleteDeleteClause{Field: f}} 82 | } 83 | 84 | type SoftDeleteDeleteClause struct { 85 | Field *schema.Field 86 | } 87 | 88 | func (sd SoftDeleteDeleteClause) Name() string { 89 | return "" 90 | } 91 | 92 | func (sd SoftDeleteDeleteClause) Build(clause.Builder) { 93 | } 94 | 95 | func (sd SoftDeleteDeleteClause) MergeClause(*clause.Clause) { 96 | } 97 | 98 | func (sd SoftDeleteDeleteClause) ModifyStatement(stmt *gorm.Statement) { 99 | if stmt.SQL.String() == "" { 100 | stmt.AddClause(clause.Set{{Column: clause.Column{Name: sd.Field.DBName}, Value: stmt.DB.NowFunc().UnixNano()}}) 101 | 102 | if stmt.Schema != nil { 103 | _, queryValues := schema.GetIdentityFieldValuesMap(stmt.ReflectValue, stmt.Schema.PrimaryFields) 104 | column, values := schema.ToQueryValues(stmt.Table, stmt.Schema.PrimaryFieldDBNames, queryValues) 105 | 106 | if len(values) > 0 { 107 | stmt.AddClause(clause.Where{Exprs: []clause.Expression{clause.IN{Column: column, Values: values}}}) 108 | } 109 | 110 | if stmt.ReflectValue.CanAddr() && stmt.Dest != stmt.Model && stmt.Model != nil { 111 | _, queryValues = schema.GetIdentityFieldValuesMap(reflect.ValueOf(stmt.Model), stmt.Schema.PrimaryFields) 112 | column, values = schema.ToQueryValues(stmt.Table, stmt.Schema.PrimaryFieldDBNames, queryValues) 113 | 114 | if len(values) > 0 { 115 | stmt.AddClause(clause.Where{Exprs: []clause.Expression{clause.IN{Column: column, Values: values}}}) 116 | } 117 | } 118 | } 119 | 120 | stmt.AddClauseIfNotExists(clause.Update{}) 121 | stmt.Build("UPDATE", "SET", "WHERE") 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /models/mark.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "gorm.io/gorm" 7 | "net/url" 8 | "strings" 9 | ) 10 | 11 | type Mark struct { 12 | BaseModel 13 | UserID uint `gorm:"not null" json:"user_id" binding:"required"` 14 | URL string `gorm:"type:varchar(255);index" json:"url" binding:"required"` 15 | Tag string `gorm:"type:varchar(255);comment:color or other tag;not null" json:"tag" binding:"required"` 16 | HashKey string `gorm:"type:varchar(255);not null" json:"hash_key" binding:"required"` 17 | Selection Selection `gorm:"type:text;not null" json:"selection" binding:"required"` 18 | 19 | Comment *Comment `json:"comment,omitempty"` // has one 20 | } 21 | 22 | type MarkCreateVO struct { 23 | URL string `json:"url" binding:"required"` 24 | Tag string `json:"tag" binding:"required"` 25 | HashKey string `json:"hash_key" binding:"required"` 26 | Selection Selection `json:"selection" binding:"required"` 27 | } 28 | 29 | type MarkUpdateVO struct { 30 | Tag string `json:"tag"` 31 | //Selection Selection `json:"selection"` 32 | } 33 | 34 | func MarkFindByHashKey(userID uint, hashKey string) (mark *Mark, err error) { 35 | if hashKey == "" { 36 | return nil, gorm.ErrRecordNotFound 37 | } 38 | mark = &Mark{} 39 | err = DB().Preload("Comment").Where("user_id = ? AND hash_key = ?", userID, hashKey).First(mark).Error 40 | return 41 | } 42 | 43 | func MarkFind(markID uint) (mark *Mark, err error) { 44 | mark = &Mark{} 45 | err = DB().Preload("Comment").First(mark, markID).Error 46 | return mark, err 47 | } 48 | 49 | func MarkQuery(userID uint, safeBase64URL string) []*Mark { 50 | marks := make([]*Mark, 0) 51 | var err error 52 | if safeBase64URL, err = url.QueryUnescape(safeBase64URL); err == nil { 53 | if urlBytes, err := base64.StdEncoding.DecodeString(safeBase64URL); err == nil { 54 | DB().Preload("Comment").Where("user_id = ? AND url = ?", userID, string(urlBytes)).Find(&marks) 55 | } 56 | } 57 | return marks 58 | } 59 | 60 | func MarkCreate(userID uint, vo *MarkCreateVO) (mark *Mark, err error) { 61 | mark = &Mark{ 62 | UserID: userID, 63 | URL: vo.URL, 64 | Tag: vo.Tag, 65 | HashKey: vo.HashKey, 66 | Selection: vo.Selection, 67 | } 68 | err = DB().Create(mark).Error 69 | return mark, err 70 | } 71 | 72 | func MarkUpdate(userID uint, hashKey string, vo *MarkUpdateVO) (mark *Mark, err error) { 73 | mark, err = MarkFindByHashKey(userID, hashKey) 74 | if err != nil { 75 | return nil, err 76 | } 77 | mark.Tag = vo.Tag 78 | //mark.Selection = vo.Selection 79 | err = DB().Updates(mark).Error 80 | return mark, err 81 | } 82 | 83 | func MarkDestroy(userID uint, hashKey string) (mark *Mark, err error) { 84 | mark, err = MarkFindByHashKey(userID, hashKey) 85 | if err != nil { 86 | return nil, err 87 | } 88 | err = DB().Select("Comment").Delete(&mark).Error 89 | return mark, err 90 | } 91 | 92 | func MarkList(userID uint, vo PaginationVO) []Mark { 93 | var marks []Mark 94 | DB().Scopes(PaginationScope(vo)).Preload("Comment").Where("user_id = ?", userID).Find(&marks) 95 | return marks 96 | } 97 | 98 | func MarkTotal(userID uint) (total int64) { 99 | DB().Model(&Mark{}).Where("user_id = ?", userID).Count(&total) 100 | return total 101 | } 102 | 103 | const HTMLSplitTag = "\t" 104 | 105 | func (m *Mark) SelectionText() string { 106 | texts := m.Selection.Texts 107 | if len(texts) == 1 { 108 | runes := []rune(texts[0]) 109 | subRunes := string(runes[m.Selection.StartOffset:m.Selection.EndOffset]) 110 | return strings.TrimSpace(subRunes) 111 | } 112 | 113 | firstUnicodeStr := []rune(texts[0]) 114 | texts[0] = string(firstUnicodeStr[m.Selection.StartOffset:len(firstUnicodeStr)]) 115 | 116 | lastIndex := len(texts) - 1 117 | lastUnicodeStr := []rune(texts[lastIndex]) 118 | texts[lastIndex] = string(lastUnicodeStr[0:m.Selection.EndOffset]) 119 | 120 | var buffer bytes.Buffer 121 | for i, s := range texts { 122 | if i > 0 { 123 | buffer.WriteString(HTMLSplitTag) 124 | } 125 | buffer.WriteString(strings.TrimSpace(s)) 126 | } 127 | return buffer.String() 128 | } 129 | -------------------------------------------------------------------------------- /controllers/api/v1/marks_controller_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "github.com/gin-gonic/gin" 9 | "github.com/icbd/gohighlights/controllers/middleware" 10 | "github.com/icbd/gohighlights/indices" 11 | "github.com/icbd/gohighlights/models" 12 | "github.com/icbd/gohighlights/utils" 13 | "github.com/spf13/cast" 14 | "github.com/stretchr/testify/assert" 15 | "gorm.io/gorm" 16 | "net/http" 17 | "net/http/httptest" 18 | "net/url" 19 | "testing" 20 | ) 21 | 22 | var currentSession *models.Session 23 | 24 | func init() { 25 | gin.SetMode(gin.TestMode) 26 | indices.Enable = false 27 | 28 | currentSession, _ = models.FakeUser().GenerateSession() 29 | } 30 | 31 | func TestMarksCreate(t *testing.T) { 32 | assert := assert.New(t) 33 | 34 | params := gin.H{ 35 | "url": "http://localhost/", 36 | "tag": "blue", 37 | "hash_key": utils.GenerateToken(10), 38 | "selection": models.FakeSelection(), 39 | } 40 | body, _ := json.Marshal(params) 41 | 42 | w := httptest.NewRecorder() 43 | ctx, _ := gin.CreateTestContext(w) 44 | ctx.Set(middleware.CurrentUser, currentSession.User) 45 | ctx.Request = httptest.NewRequest("POST", "/api/v1/marks", bytes.NewReader(body)) 46 | MarksCreate(ctx) 47 | 48 | assert.Equal(http.StatusCreated, w.Code) 49 | 50 | var resp map[string]interface{} 51 | _ = json.Unmarshal(w.Body.Bytes(), &resp) 52 | assert.True(cast.ToUint(resp["id"]) != 0) 53 | } 54 | 55 | func TestMarksUpdate(t *testing.T) { 56 | assert := assert.New(t) 57 | 58 | oldMark := models.FakeMark(currentSession.UserID) 59 | 60 | params := gin.H{"tag": "DIY"} 61 | body, _ := json.Marshal(params) 62 | 63 | w := httptest.NewRecorder() 64 | ctx, _ := gin.CreateTestContext(w) 65 | ctx.Set(middleware.CurrentUser, currentSession.User) 66 | ctx.Request = httptest.NewRequest("PATCH", "/api/vi/marks/:hash_key", bytes.NewReader(body)) 67 | ctx.Params = gin.Params{gin.Param{Key: "hash_key", Value: oldMark.HashKey}} 68 | MarksUpdate(ctx) 69 | 70 | assert.Equal(http.StatusOK, w.Code) 71 | 72 | var resp map[string]interface{} 73 | _ = json.Unmarshal(w.Body.Bytes(), &resp) 74 | assert.Equal("DIY", cast.ToString(resp["tag"])) 75 | } 76 | 77 | func TestMarksDestroy(t *testing.T) { 78 | assert := assert.New(t) 79 | 80 | oldMark := models.FakeMark(currentSession.UserID) 81 | 82 | w := httptest.NewRecorder() 83 | ctx, _ := gin.CreateTestContext(w) 84 | ctx.Set(middleware.CurrentUser, currentSession.User) 85 | ctx.Request = httptest.NewRequest("DELETE", "/api/vi/marks/:hash_key", nil) 86 | ctx.Params = gin.Params{gin.Param{Key: "hash_key", Value: oldMark.HashKey}} 87 | MarksDestroy(ctx) 88 | 89 | assert.Equal(http.StatusNoContent, w.Code) 90 | 91 | _, err := models.MarkFind(oldMark.ID) 92 | assert.True(errors.Is(err, gorm.ErrRecordNotFound)) 93 | } 94 | 95 | func TestMarksQuery(t *testing.T) { 96 | assert := assert.New(t) 97 | 98 | oldMark := models.FakeMark(currentSession.UserID) 99 | 100 | escapedBase64URL := url.QueryEscape(base64.StdEncoding.EncodeToString([]byte(oldMark.URL))) 101 | 102 | w := httptest.NewRecorder() 103 | ctx, _ := gin.CreateTestContext(w) 104 | ctx.Set(middleware.CurrentUser, currentSession.User) 105 | ctx.Request = httptest.NewRequest("GET", "/api/vi/marks/query?url="+escapedBase64URL, nil) 106 | ctx.Params = gin.Params{gin.Param{Key: "hash_key", Value: oldMark.HashKey}} 107 | MarksQuery(ctx) 108 | 109 | assert.Equal(http.StatusOK, w.Code) 110 | 111 | var resp [](map[string]interface{}) 112 | _ = json.Unmarshal(w.Body.Bytes(), &resp) 113 | assert.True(len(resp) > 0) 114 | 115 | hash_keys := make(map[string]uint) 116 | for _, item := range resp { 117 | hash_keys[cast.ToString(item["hash_key"])] = cast.ToUint(item["id"]) 118 | } 119 | _, ok := hash_keys[oldMark.HashKey] 120 | assert.True(ok) 121 | } 122 | 123 | func TestMarksSearch(t *testing.T) { 124 | assert := assert.New(t) 125 | 126 | w := httptest.NewRecorder() 127 | ctx, _ := gin.CreateTestContext(w) 128 | ctx.Set(middleware.CurrentUser, currentSession.User) 129 | ctx.Request = httptest.NewRequest("GET", "/api/vi/marks/search?q=xxx", nil) 130 | MarksSearch(ctx) 131 | 132 | assert.Equal(http.StatusOK, w.Code) 133 | 134 | var resp [](map[string]interface{}) 135 | _ = json.Unmarshal(w.Body.Bytes(), &resp) 136 | assert.True(resp != nil) 137 | } 138 | -------------------------------------------------------------------------------- /indices/mark_index.go: -------------------------------------------------------------------------------- 1 | package indices 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/icbd/gohighlights/models" 8 | "github.com/jinzhu/copier" 9 | "github.com/olivere/elastic/v7" 10 | "github.com/spf13/cast" 11 | "time" 12 | ) 13 | 14 | const MarkIndexName = "marks" 15 | const MarkIndexMapping = ` 16 | { 17 | "settings": { 18 | "number_of_shards": 1, 19 | "number_of_replicas": 0 20 | }, 21 | "mappings": { 22 | "properties": { 23 | "id": { 24 | "type": "long" 25 | }, 26 | "created_at": { 27 | "type": "date" 28 | }, 29 | "updated_at": { 30 | "type": "date" 31 | }, 32 | "user_id": { 33 | "type": "long" 34 | }, 35 | "url": { 36 | "type": "keyword" 37 | }, 38 | "tag": { 39 | "type": "keyword" 40 | }, 41 | "hash_key": { 42 | "type": "keyword" 43 | }, 44 | "selection": { 45 | "type": "text", 46 | "analyzer": "ik_max_word", 47 | "search_analyzer": "ik_smart" 48 | }, 49 | "comment": { 50 | "type": "text", 51 | "analyzer": "ik_max_word", 52 | "search_analyzer": "ik_smart" 53 | } 54 | } 55 | } 56 | }` 57 | 58 | func SetupMarkIndex() error { 59 | if !Enable { 60 | return nil 61 | } 62 | ctx := context.Background() 63 | exists, err := Client().IndexExists(IndexName(MarkIndexName)).Do(ctx) 64 | if err != nil { 65 | return err 66 | } 67 | if exists { 68 | return nil 69 | } 70 | createIndex, err := Client().CreateIndex(IndexName(MarkIndexName)).BodyString(MarkIndexMapping).Do(ctx) 71 | if err != nil { 72 | return err 73 | } 74 | if !createIndex.Acknowledged { 75 | return fmt.Errorf("create %s index failed", IndexName(MarkIndexName)) 76 | } 77 | return nil 78 | } 79 | 80 | type MarkIndex struct { 81 | err error 82 | 83 | ID uint `json:"id"` 84 | CreatedAt time.Time `json:"created_at"` 85 | UpdatedAt time.Time `json:"updated_at"` 86 | 87 | UserID uint `json:"user_id"` 88 | URL string `json:"url"` 89 | Tag string `json:"tag"` 90 | HashKey string `json:"hash_key"` 91 | Selection string `json:"selection"` 92 | Comment string `json:"comment"` 93 | } 94 | 95 | func NewMarkIndex(m *models.Mark) (markIndex *MarkIndex) { 96 | markIndex = &MarkIndex{} 97 | if !Enable { 98 | markIndex.err = fmt.Errorf("!use") 99 | return 100 | } 101 | 102 | if err := copier.Copy(&markIndex, m); err != nil { 103 | markIndex.err = err 104 | return 105 | } 106 | markIndex.Selection = m.SelectionText() 107 | if m.Comment != nil { 108 | markIndex.Comment = m.Comment.Content 109 | } 110 | return 111 | } 112 | 113 | func (m *MarkIndex) Fresh() (*elastic.IndexResponse, error) { 114 | if !Enable { 115 | return nil, NotEnabledError 116 | } 117 | return Client(). 118 | Index(). 119 | Index(IndexName(MarkIndexName)). 120 | Id(cast.ToString(m.ID)). 121 | BodyJson(m). 122 | Do(context.Background()) 123 | } 124 | 125 | func MarkIndexDelete(markID uint) (*elastic.DeleteResponse, error) { 126 | if !Enable { 127 | return nil, NotEnabledError 128 | } 129 | return Client(). 130 | Delete(). 131 | Index(IndexName(MarkIndexName)). 132 | Id(cast.ToString(markID)). 133 | Do(context.Background()) 134 | } 135 | 136 | /** 137 | GET /mark/_search 138 | { 139 | "query": { 140 | "bool": { 141 | "filter": { 142 | "term": { 143 | "user_id": 1 144 | } 145 | }, 146 | "minimum_should_match": "1", 147 | "should": [ 148 | { 149 | "match": { 150 | "selection": { 151 | "query": "like" 152 | } 153 | } 154 | }, 155 | { 156 | "match": { 157 | "comment": { 158 | "query": "like" 159 | } 160 | } 161 | } 162 | ] 163 | } 164 | }, 165 | "sort": [ 166 | { 167 | "id": { 168 | "order": "desc" 169 | } 170 | } 171 | ] 172 | } 173 | */ 174 | func Query(u *models.User, text string) (marks []*MarkIndex) { 175 | marks = make([]*MarkIndex, 0) 176 | if !Enable { 177 | return marks 178 | } 179 | boolQuery := elastic.NewBoolQuery() 180 | boolQuery.Filter(elastic.NewTermQuery("user_id", u.ID)) 181 | boolQuery.Should( 182 | elastic.NewMatchQuery("selection", text), 183 | elastic.NewMatchQuery("comment", text), 184 | ) 185 | boolQuery.MinimumNumberShouldMatch(1) 186 | result, err := Client(). 187 | Search(). 188 | Index(IndexName(MarkIndexName)). 189 | Query(boolQuery). 190 | Sort("id", false). 191 | Do(context.Background()) 192 | if err != nil { 193 | return 194 | } 195 | 196 | for _, item := range result.Hits.Hits { 197 | m := MarkIndex{} 198 | if err := json.Unmarshal(item.Source, &m); err == nil { 199 | marks = append(marks, &m) 200 | } 201 | } 202 | return marks 203 | } 204 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= 4 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= 5 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 6 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= 7 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= 8 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= 9 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= 10 | cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= 11 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= 12 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= 13 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 14 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 15 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 16 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 17 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 18 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 19 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 20 | github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= 21 | github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= 22 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 23 | github.com/aws/aws-sdk-go v1.35.20/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k= 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/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 27 | github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= 28 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 29 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 30 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= 31 | github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 39 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 40 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 41 | github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= 42 | github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= 43 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 44 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 45 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 46 | github.com/gin-contrib/cors v1.3.1 h1:doAsuITavI4IOcd0Y19U4B+O0dNWihRyX//nn4sEmgA= 47 | github.com/gin-contrib/cors v1.3.1/go.mod h1:jjEJ4268OPZUcU7k9Pm653S7lXUGcqMADzFA61xsmDk= 48 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 49 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 50 | github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= 51 | github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= 52 | github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= 53 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 54 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 55 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 56 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 57 | github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= 58 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 59 | github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= 60 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 61 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 62 | github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= 63 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 64 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 65 | github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= 66 | github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= 67 | github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= 68 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 69 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 70 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 71 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 72 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 73 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 74 | github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 75 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 76 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 77 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 78 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 79 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 80 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 81 | github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I= 82 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 83 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 84 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 85 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 86 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 87 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 88 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 89 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 90 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 91 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 92 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 93 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 94 | github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= 95 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 96 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 97 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 98 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 99 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 100 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 101 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= 102 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 103 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= 104 | github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= 105 | github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= 106 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 107 | github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 108 | github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= 109 | github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= 110 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 111 | github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= 112 | github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= 113 | github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= 114 | github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 115 | github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 116 | github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= 117 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 118 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 119 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 120 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 121 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 122 | github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= 123 | github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= 124 | github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= 125 | github.com/icbd/gorm-migration v0.0.3 h1:mGtcd4u1svzJadSKw2+cuoDjih7og4r45r3gjuC0l9Q= 126 | github.com/icbd/gorm-migration v0.0.3/go.mod h1:SdMgP6WhRl0KpqZUkgU4XyFtQ3gGGNdojrVB6GZLf7E= 127 | github.com/jinzhu/copier v0.0.0-20201025035756-632e723a6687 h1:bWXum+xWafUxxJpcXnystwg5m3iVpPYtrGJFc1rjfLc= 128 | github.com/jinzhu/copier v0.0.0-20201025035756-632e723a6687/go.mod h1:24xnZezI2Yqac9J61UC6/dG/k76ttpq0DdJI3QmUvro= 129 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 130 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 131 | github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E= 132 | github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 133 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 134 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 135 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 136 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 137 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 138 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 139 | github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 140 | github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= 141 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 142 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 143 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 144 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 145 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 146 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 147 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 148 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 149 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 150 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 151 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 152 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 153 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 154 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 155 | github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= 156 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 157 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 158 | github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= 159 | github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 160 | github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= 161 | github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 162 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 163 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 164 | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= 165 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 166 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 167 | github.com/mattn/go-sqlite3 v1.14.3 h1:j7a/xn1U6TKA/PHHxqZuzh64CdtRc7rU9M+AvkOl5bA= 168 | github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= 169 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 170 | github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= 171 | github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= 172 | github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 173 | github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= 174 | github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= 175 | github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= 176 | github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 177 | github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= 178 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 179 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= 180 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 181 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 182 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 183 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= 184 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 185 | github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= 186 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 187 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 188 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 189 | github.com/olivere/elastic/v7 v7.0.22 h1:esBA6JJwvYgfms0EVlH7Z+9J4oQ/WUADF2y/nCNDw7s= 190 | github.com/olivere/elastic/v7 v7.0.22/go.mod h1:VDexNy9NjmtAkrjNoI7tImv7FR4tf5zUA3ickqu5Pc8= 191 | github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 192 | github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= 193 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 194 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 195 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 196 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 197 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 198 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 199 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 200 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 201 | github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= 202 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 203 | github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= 204 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 205 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 206 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 207 | github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 208 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 209 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 210 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 211 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 212 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 213 | github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= 214 | github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= 215 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 216 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= 217 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 218 | github.com/smartystreets/assertions v1.1.1 h1:T/YLemO5Yp7KPzS+lVtu+WsHn8yoSwTfItdAd1r3cck= 219 | github.com/smartystreets/assertions v1.1.1/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 220 | github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= 221 | github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= 222 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 223 | github.com/smartystreets/gunit v1.4.2/go.mod h1:ZjM1ozSIMJlAz/ay4SG8PeKF00ckUp+zMHZXV9/bvak= 224 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= 225 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 226 | github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= 227 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 228 | github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= 229 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 230 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 231 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 232 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 233 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 234 | github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= 235 | github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= 236 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 237 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 238 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 239 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 240 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 241 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 242 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 243 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 244 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 245 | github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= 246 | github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= 247 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 248 | github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= 249 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 250 | github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= 251 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 252 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 253 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 254 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 255 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= 256 | go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= 257 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 258 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 259 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 260 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 261 | golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 262 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 263 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 264 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5 h1:58fnuSXlxZmFdJyvtTFVmVhcMLU6v5fEb/ok4wyqtNU= 265 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 266 | golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E= 267 | golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 268 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 269 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 270 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 271 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= 272 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= 273 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 274 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 275 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 276 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 277 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 278 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 279 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 280 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 281 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 282 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 283 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 284 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 285 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 286 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 287 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 288 | golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 289 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 290 | golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 291 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 292 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 293 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 294 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 295 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 296 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 297 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 298 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 299 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 300 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 301 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 302 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 303 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 304 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 305 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 306 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 307 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 308 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 309 | golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 310 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 311 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 312 | golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 313 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 314 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 315 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 316 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 317 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 318 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 319 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 320 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 321 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 322 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 323 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= 324 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 325 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 326 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 327 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 328 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 329 | golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= 330 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 331 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 332 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 333 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 334 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 335 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 336 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 337 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 338 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 339 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 340 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 341 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 342 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 343 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 344 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 345 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 346 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 347 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 348 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 349 | golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 350 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 351 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 352 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 353 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 354 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= 355 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 356 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= 357 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= 358 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 359 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 360 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 361 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 362 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 363 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 364 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 365 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 366 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 367 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 368 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 369 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 370 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= 371 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 372 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 373 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 374 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 375 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 376 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 377 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 378 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 379 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= 380 | gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= 381 | gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= 382 | gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 383 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= 384 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 385 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 386 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 387 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 388 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 389 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 390 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 391 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 392 | gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg= 393 | gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI= 394 | gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc= 395 | gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c= 396 | gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 397 | gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 398 | gorm.io/gorm v1.20.5 h1:g3tpSF9kggASzReK+Z3dYei1IJODLqNUbOjSuCczY8g= 399 | gorm.io/gorm v1.20.5/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 400 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 401 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 402 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 403 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 404 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 405 | --------------------------------------------------------------------------------