diff --git a/common/constants.go b/common/constants.go index 01a00ee..ca721be 100644 --- a/common/constants.go +++ b/common/constants.go @@ -113,3 +113,9 @@ const ( ChannelStatusEnabled = 1 ChannelStatusDisabled = 2 ) + +const ( + WebhookStatusUnknown = 0 + WebhookStatusEnabled = 1 + WebhookStatusDisabled = 2 +) diff --git a/controller/message.go b/controller/message.go index c22c7eb..89ad9ab 100644 --- a/controller/message.go +++ b/controller/message.go @@ -116,6 +116,10 @@ func pushMessageHelper(c *gin.Context, message *model.Message) { return } } + processMessage(c, message, &user) +} + +func processMessage(c *gin.Context, message *model.Message, user *model.User) { if message.Title == "" { message.Title = common.SystemName } @@ -133,7 +137,7 @@ func pushMessageHelper(c *gin.Context, message *model.Message) { }) return } - err = saveAndSendMessage(&user, message, channel_) + err = saveAndSendMessage(user, message, channel_) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, diff --git a/controller/webhook.go b/controller/webhook.go new file mode 100644 index 0000000..624b041 --- /dev/null +++ b/controller/webhook.go @@ -0,0 +1,256 @@ +package controller + +import ( + "encoding/json" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "message-pusher/common" + "message-pusher/model" + "net/http" + "strconv" + "strings" +) + +func GetAllWebhooks(c *gin.Context) { + userId := c.GetInt("id") + p, _ := strconv.Atoi(c.Query("p")) + if p < 0 { + p = 0 + } + webhooks, err := model.GetWebhooksByUserId(userId, p*common.ItemsPerPage, common.ItemsPerPage) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": webhooks, + }) + return +} + +func SearchWebhooks(c *gin.Context) { + userId := c.GetInt("id") + keyword := c.Query("keyword") + webhooks, err := model.SearchWebhooks(userId, keyword) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": webhooks, + }) + return +} + +func GetWebhook(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + userId := c.GetInt("id") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + webhook_, err := model.GetWebhookById(id, userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": webhook_, + }) + return +} + +func AddWebhook(c *gin.Context) { + webhook_ := model.Webhook{} + err := c.ShouldBindJSON(&webhook_) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if len(webhook_.Name) == 0 || len(webhook_.Name) > 20 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "通道名称长度必须在1-20之间", + }) + return + } + cleanWebhook := model.Webhook{ + UserId: c.GetInt("id"), + Name: webhook_.Name, + Status: common.WebhookStatusEnabled, + Link: common.GetUUID(), + CreatedTime: common.GetTimestamp(), + ExtractRule: webhook_.ExtractRule, + } + err = cleanWebhook.Insert() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func DeleteWebhook(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + userId := c.GetInt("id") + _, err := model.DeleteWebhookById(id, userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func UpdateWebhook(c *gin.Context) { + userId := c.GetInt("id") + statusOnly := c.Query("status_only") + webhook_ := model.Webhook{} + err := c.ShouldBindJSON(&webhook_) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + oldWebhook, err := model.GetWebhookById(webhook_.Id, userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + cleanWebhook := *oldWebhook + if statusOnly != "" { + cleanWebhook.Status = webhook_.Status + } else { + // If you add more fields, please also update webhook_.Update() + cleanWebhook.Name = webhook_.Name + cleanWebhook.ExtractRule = webhook_.ExtractRule + cleanWebhook.ConstructRule = webhook_.ConstructRule + cleanWebhook.Channel = webhook_.Channel + } + err = cleanWebhook.Update() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": cleanWebhook, + }) + return +} + +func TriggerWebhook(c *gin.Context) { + var reqText string + err := c.Bind(&reqText) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + link := c.Param("link") + webhook, err := model.GetWebhookByLink(link) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "Webhook 不存在", + }) + return + } + if webhook.Status != common.WebhookStatusEnabled { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "Webhook 未启用", + }) + return + } + user, err := model.GetUserById(webhook.UserId, false) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "用户不存在", + }) + return + } + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "用户已被封禁", + }) + return + } + extractRule := make(map[string]string) + err = json.Unmarshal([]byte(webhook.ExtractRule), &extractRule) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Webhook 提取规则解析失败", + }) + return + } + for key, value := range extractRule { + variableValue := gjson.Get(reqText, value).String() + webhook.ConstructRule = strings.Replace(webhook.ConstructRule, "$"+key, variableValue, -1) + } + constructRule := model.WebhookConstructRule{} + err = json.Unmarshal([]byte(webhook.ConstructRule), &constructRule) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Webhook 构建规则解析失败", + }) + return + } + message := &model.Message{ + Channel: webhook.Channel, + Title: constructRule.Title, + Description: constructRule.Description, + Content: constructRule.Content, + URL: constructRule.URL, + } + processMessage(c, message, user) +} diff --git a/go.mod b/go.mod index 8471427..90de6e5 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,9 @@ require ( github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.9 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect diff --git a/go.sum b/go.sum index d454381..c928fe7 100644 --- a/go.sum +++ b/go.sum @@ -122,6 +122,13 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= diff --git a/model/main.go b/model/main.go index 44740dc..f5bdf8a 100644 --- a/model/main.go +++ b/model/main.go @@ -68,6 +68,10 @@ func InitDB() (err error) { if err != nil { return err } + err = db.AutoMigrate(&Webhook{}) + if err != nil { + return err + } err = createRootAccountIfNeed() return err } else { diff --git a/model/webhook.go b/model/webhook.go new file mode 100644 index 0000000..33d5de5 --- /dev/null +++ b/model/webhook.go @@ -0,0 +1,89 @@ +package model + +import ( + "errors" +) + +// WebhookConstructRule Keep compatible with Message +type WebhookConstructRule struct { + Title string `json:"title"` + Description string `json:"description"` + Content string `json:"content"` + URL string `json:"url"` +} + +type Webhook struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + Name string `json:"name" gorm:"type:varchar(32);index"` + Status int `json:"status" gorm:"default:1"` // enabled, disabled + Link string `json:"link" gorm:"type:char(32);uniqueIndex"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + ExtractRule string `json:"extract_rule" gorm:"not null"` // how we extract key info from the request + ConstructRule string `json:"construct_rule" gorm:"not null"` // how we construct message with the extracted info + Channel string `json:"channel" gorm:"type:varchar(32); not null"` // which channel to send our message +} + +func GetWebhookById(id int, userId int) (*Webhook, error) { + if id == 0 || userId == 0 { + return nil, errors.New("id 或 userId 为空!") + } + c := Webhook{Id: id, UserId: userId} + err := DB.Where(c).First(&c).Error + return &c, err +} + +func GetWebhookByLink(link string) (*Webhook, error) { + if link == "" { + return nil, errors.New("link 为空!") + } + c := Webhook{Link: link} + err := DB.Where(c).First(&c).Error + return &c, err +} + +func GetWebhooksByUserId(userId int, startIdx int, num int) (webhooks []*Webhook, err error) { + err = DB.Where("user_id = ?", userId).Order("id desc").Limit(num).Offset(startIdx).Find(&webhooks).Error + return webhooks, err +} + +func SearchWebhooks(userId int, keyword string) (webhooks []*Webhook, err error) { + err = DB.Where("user_id = ?", userId).Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&webhooks).Error + return webhooks, err +} + +func DeleteWebhookById(id int, userId int) (c *Webhook, err error) { + // Why we need userId here? In case user want to delete other's c. + if id == 0 || userId == 0 { + return nil, errors.New("id 或 userId 为空!") + } + c = &Webhook{Id: id, UserId: userId} + err = DB.Where(c).First(&c).Error + if err != nil { + return nil, err + } + return c, c.Delete() +} + +func (webhook *Webhook) Insert() error { + var err error + err = DB.Create(webhook).Error + return err +} + +func (webhook *Webhook) UpdateStatus(status int) error { + err := DB.Model(webhook).Update("status", status).Error + return err +} + +// Update Make sure your token's fields is completed, because this will update non-zero values +func (webhook *Webhook) Update() error { + var err error + err = DB.Model(webhook).Select("name", "extract_rule", "construct_rule", "channel").Updates(webhook).Error + return err +} + +func (webhook *Webhook) Delete() error { + err := DB.Delete(webhook).Error + return err +} diff --git a/router/api-router.go b/router/api-router.go index 6570741..5786d87 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -75,6 +75,16 @@ func SetApiRouter(router *gin.Engine) { channelRoute.PUT("/", controller.UpdateChannel) channelRoute.DELETE("/:id", controller.DeleteChannel) } + webhookRoute := apiRouter.Group("/webhook") + webhookRoute.Use(middleware.UserAuth()) + { + webhookRoute.GET("/", controller.GetAllWebhooks) + webhookRoute.GET("/search", controller.SearchWebhooks) + webhookRoute.GET("/:id", controller.GetWebhook) + webhookRoute.POST("/", controller.AddWebhook) + webhookRoute.PUT("/", controller.UpdateWebhook) + webhookRoute.DELETE("/:id", controller.DeleteWebhook) + } } pushRouter := router.Group("/push") pushRouter.Use(middleware.GlobalAPIRateLimit()) @@ -82,4 +92,9 @@ func SetApiRouter(router *gin.Engine) { pushRouter.GET("/:username", controller.GetPushMessage) pushRouter.POST("/:username", controller.PostPushMessage) } + webhookRouter := router.Group("/webhook") + webhookRouter.Use(middleware.GlobalAPIRateLimit()) + { + webhookRouter.POST("/:link", controller.TriggerWebhook) + } }