From 9f656bdefed259f5ee00368002c7450466a5d33d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sat, 12 Aug 2023 19:04:04 +0200 Subject: [PATCH] Refactor message sending into logic package (+ more tests for uptime-kuma) --- scnserver/TODO.md | 161 ----------- scnserver/api/handler/compat.go | 12 +- scnserver/api/handler/external.go | 134 +++++++++ scnserver/api/handler/message.go | 292 +------------------ scnserver/api/router.go | 26 +- scnserver/logic/message.go | 222 +++++++++++++++ scnserver/test/send_test.go | 40 --- scnserver/test/uptimekuma_test.go | 447 ++++++++++++++++++++++++++++++ scnserver/test/util/formData.go | 5 + scnserver/test/util/requests.go | 3 + 10 files changed, 832 insertions(+), 510 deletions(-) create mode 100644 scnserver/api/handler/external.go create mode 100644 scnserver/logic/message.go create mode 100644 scnserver/test/uptimekuma_test.go diff --git a/scnserver/TODO.md b/scnserver/TODO.md index 52638ee..ac167c1 100644 --- a/scnserver/TODO.md +++ b/scnserver/TODO.md @@ -49,167 +49,6 @@ - route to re-check all pro-token (for me) - - /send endpoint should be compatible with the [ webhook ] notifier of uptime-kuma - (or add another /kuma endpoint) - -> https://webhook.site/ - - -```````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````` - - - -{"heartbeat":{"monitorID":89,"status":0,"time":"2023-07-31 18:56:15.374","msg":"timeout of 16000ms exceeded","important":true,"duration":36,"timezone":"Europe/Berlin","timezoneOffset":"+02:00","localDateTime":"2023-07-31 20:56:15"},"monitor":{"id":89,"name":"test","description":null,"pathName":"test","parent":null,"childrenIDs":[],"url":"https://exampleXYZ.com","method":"GET","hostname":null,"port":null,"maxretries":1,"weight":2000,"active":true,"forceInactive":false,"type":"http","interval":20,"retryInterval":20,"resendInterval":0,"keyword":null,"expiryNotification":false,"ignoreTls":false,"upsideDown":false,"packetSize":56,"maxredirects":10,"accepted_statuscodes":["200-299"],"dns_resolve_type":"A","dns_resolve_server":"1.1.1.1","dns_last_result":null,"docker_container":"","docker_host":null,"proxyId":null,"notificationIDList":{"2":true},"tags":[],"maintenance":false,"mqttTopic":"","mqttSuccessMessage":"","databaseQuery":null,"authMethod":null,"grpcUrl":null,"grpcProtobuf":null,"grpcMethod":null,"grpcServiceName":null,"grpcEnableTls":false,"radiusCalledStationId":null,"radiusCallingStationId":null,"game":null,"httpBodyEncoding":"json","includeSensitiveData":false},"msg":"[test] [🔴 Down] timeout of 16000ms exceeded"} - - -===================================================================================================================================================================================================== - - -{ - "heartbeat": { - "monitorID": 89, - "status": 1, - "time": "2023-07-31 18:56:57.151", - "msg": "200 - OK", - "ping": 55, - "important": true, - "duration": 41, - "timezone": "Europe/Berlin", - "timezoneOffset": "+02:00", - "localDateTime": "2023-07-31 20:56:57" - }, - "monitor": { - "id": 89, - "name": "test", - "description": null, - "pathName": "test", - "parent": null, - "childrenIDs": [], - "url": "https://example.com", - "method": "GET", - "hostname": null, - "port": null, - "maxretries": 1, - "weight": 2000, - "active": true, - "forceInactive": false, - "type": "http", - "interval": 20, - "retryInterval": 20, - "resendInterval": 0, - "keyword": null, - "expiryNotification": false, - "ignoreTls": false, - "upsideDown": false, - "packetSize": 56, - "maxredirects": 10, - "accepted_statuscodes": [ - "200-299" - ], - "dns_resolve_type": "A", - "dns_resolve_server": "1.1.1.1", - "dns_last_result": null, - "docker_container": "", - "docker_host": null, - "proxyId": null, - "notificationIDList": { - "2": true - }, - "tags": [], - "maintenance": false, - "mqttTopic": "", - "mqttSuccessMessage": "", - "databaseQuery": null, - "authMethod": null, - "grpcUrl": null, - "grpcProtobuf": null, - "grpcMethod": null, - "grpcServiceName": null, - "grpcEnableTls": false, - "radiusCalledStationId": null, - "radiusCallingStationId": null, - "game": null, - "httpBodyEncoding": "json", - "includeSensitiveData": false - }, - "msg": "[test] [✅ Up] 200 - OK" -} - - -===================================================================================================================================================================================================== - - -{ - "heartbeat": { - "monitorID": 89, - "status": 0, - "time": "2023-07-31 18:57:44.037", - "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", - "important": true, - "duration": 20, - "timezone": "Europe/Berlin", - "timezoneOffset": "+02:00", - "localDateTime": "2023-07-31 20:57:44" - }, - "monitor": { - "id": 89, - "name": "test", - "description": null, - "pathName": "test", - "parent": null, - "childrenIDs": [], - "url": "https://exampleasdsda.com", - "method": "GET", - "hostname": null, - "port": null, - "maxretries": 1, - "weight": 2000, - "active": true, - "forceInactive": false, - "type": "http", - "interval": 20, - "retryInterval": 20, - "resendInterval": 0, - "keyword": null, - "expiryNotification": false, - "ignoreTls": false, - "upsideDown": false, - "packetSize": 56, - "maxredirects": 10, - "accepted_statuscodes": [ - "200-299" - ], - "dns_resolve_type": "A", - "dns_resolve_server": "1.1.1.1", - "dns_last_result": null, - "docker_container": "", - "docker_host": null, - "proxyId": null, - "notificationIDList": { - "2": true - }, - "tags": [], - "maintenance": false, - "mqttTopic": "", - "mqttSuccessMessage": "", - "databaseQuery": null, - "authMethod": null, - "grpcUrl": null, - "grpcProtobuf": null, - "grpcMethod": null, - "grpcServiceName": null, - "grpcEnableTls": false, - "radiusCalledStationId": null, - "radiusCallingStationId": null, - "game": null, - "httpBodyEncoding": "json", - "includeSensitiveData": false - }, - "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com" -} - -```````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````` - - - endpoint to list all servernames of user (distinct select) - weblogin, webapp, ... diff --git a/scnserver/api/handler/compat.go b/scnserver/api/handler/compat.go index c7e7de5..eac6a80 100644 --- a/scnserver/api/handler/compat.go +++ b/scnserver/api/handler/compat.go @@ -29,7 +29,7 @@ func NewCompatHandler(app *logic.Application) CompatHandler { } } -// SendMessageCompat swaggerdoc +// SendMessage swaggerdoc // // @Deprecated // @@ -37,17 +37,17 @@ func NewCompatHandler(app *logic.Application) CompatHandler { // @Description All parameter can be set via query-parameter or form-data body. Only UserID, UserKey and Title are required // @Tags External // -// @Param query_data query handler.SendMessageCompat.combined false " " -// @Param form_data formData handler.SendMessageCompat.combined false " " +// @Param query_data query handler.SendMessage.combined false " " +// @Param form_data formData handler.SendMessage.combined false " " // -// @Success 200 {object} handler.SendMessageCompat.response +// @Success 200 {object} handler.SendMessage.response // @Failure 400 {object} ginresp.apiError // @Failure 401 {object} ginresp.apiError // @Failure 403 {object} ginresp.apiError // @Failure 500 {object} ginresp.apiError // // @Router /send.php [POST] -func (h MessageHandler) SendMessageCompat(g *gin.Context) ginresp.HTTPResponse { +func (h CompatHandler) SendMessage(g *gin.Context) ginresp.HTTPResponse { type combined struct { UserID *int64 `json:"user_id" form:"user_id"` UserKey *string `json:"user_key" form:"user_key"` @@ -88,7 +88,7 @@ func (h MessageHandler) SendMessageCompat(g *gin.Context) ginresp.HTTPResponse { return ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found (compat)", nil) } - okResp, errResp := h.sendMessageInternal(g, ctx, langext.Ptr(models.UserID(*newid)), data.UserKey, nil, data.Title, data.Content, data.Priority, data.UserMessageID, data.SendTimestamp, nil) + okResp, errResp := h.app.SendMessage(g, ctx, langext.Ptr(models.UserID(*newid)), data.UserKey, nil, data.Title, data.Content, data.Priority, data.UserMessageID, data.SendTimestamp, nil) if errResp != nil { return *errResp } else { diff --git a/scnserver/api/handler/external.go b/scnserver/api/handler/external.go new file mode 100644 index 0000000..d09877d --- /dev/null +++ b/scnserver/api/handler/external.go @@ -0,0 +1,134 @@ +package handler + +import ( + "blackforestbytes.com/simplecloudnotifier/api/apierr" + "blackforestbytes.com/simplecloudnotifier/api/ginresp" + primarydb "blackforestbytes.com/simplecloudnotifier/db/impl/primary" + "blackforestbytes.com/simplecloudnotifier/logic" + "blackforestbytes.com/simplecloudnotifier/models" + "fmt" + "github.com/gin-gonic/gin" + "gogs.mikescher.com/BlackForestBytes/goext/langext" + "net/http" + "time" +) + +type ExternalHandler struct { + app *logic.Application + database *primarydb.Database +} + +func NewExternalHandler(app *logic.Application) ExternalHandler { + return ExternalHandler{ + app: app, + database: app.Database.Primary, + } +} + +// UptimeKuma swaggerdoc +// +// @Summary Send a new message +// @Description All parameter can be set via query-parameter or the json body. Only UserID, UserKey and Title are required +// @Tags External +// +// @Param query_data query handler.UptimeKuma.query false " " +// @Param post_body body handler.UptimeKuma.body false " " +// +// @Success 200 {object} handler.UptimeKuma.response +// @Failure 400 {object} ginresp.apiError +// @Failure 401 {object} ginresp.apiError "The user_id was not found or the user_key is wrong" +// @Failure 403 {object} ginresp.apiError "The user has exceeded its daily quota - wait 24 hours or upgrade your account" +// @Failure 500 {object} ginresp.apiError "An internal server error occurred - try again later" +// +// @Router /external/v1/uptime-kuma [POST] +func (h ExternalHandler) UptimeKuma(g *gin.Context) ginresp.HTTPResponse { + type query struct { + UserID *models.UserID `form:"user_id" example:"7725"` + KeyToken *string `form:"key" example:"P3TNH8mvv14fm"` + Channel *string `form:"channel"` + ChannelUp *string `form:"channel_up"` + ChannelDown *string `form:"channel_down"` + Priority *int `form:"priority"` + PriorityUp *int `form:"priority_up"` + PriorityDown *int `form:"priority_down"` + SenderName *string `form:"senderName"` + } + type body struct { + Heartbeat *struct { + Time string `json:"time"` + Status int `json:"status"` + Msg string `json:"msg"` + Timezone string `json:"timezone"` + TimezoneOffset string `json:"timezoneOffset"` + LocalDateTime string `json:"localDateTime"` + } `json:"heartbeat"` + Monitor *struct { + Name string `json:"name"` + Url *string `json:"url"` + } `json:"monitor"` + Msg *string `json:"msg"` + } + type response struct { + MessageID models.MessageID `json:"message_id"` + } + + var b body + var q query + ctx, httpErr := h.app.StartRequest(g, nil, &q, &b, nil) + if httpErr != nil { + return *httpErr + } + defer ctx.Cancel() + + if b.Heartbeat == nil { + return ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "missing field 'heartbeat' in request body", nil) + } + if b.Monitor == nil { + return ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "missing field 'monitor' in request body", nil) + } + if b.Msg == nil { + return ginresp.APIError(g, 400, apierr.BINDFAIL_BODY_PARAM, "missing field 'msg' in request body", nil) + } + + title := langext.Conditional(b.Heartbeat.Status == 1, fmt.Sprintf("Monitor %v is back online", b.Monitor.Name), fmt.Sprintf("Monitor %v went down!", b.Monitor.Name)) + + content := b.Heartbeat.Msg + + var timestamp *float64 = nil + if tz, err := time.LoadLocation(b.Heartbeat.Timezone); err == nil { + if ts, err := time.ParseInLocation("2006-01-02 15:04:05", b.Heartbeat.LocalDateTime, tz); err == nil { + timestamp = langext.Ptr(float64(ts.Unix())) + } + } + + var channel *string = nil + if q.Channel != nil { + channel = q.Channel + } + if q.ChannelUp != nil && b.Heartbeat.Status == 1 { + channel = q.ChannelUp + } + if q.ChannelDown != nil && b.Heartbeat.Status != 1 { + channel = q.ChannelDown + } + + var priority *int = nil + if q.Priority != nil { + priority = q.Priority + } + if q.PriorityUp != nil && b.Heartbeat.Status == 1 { + priority = q.PriorityUp + } + if q.PriorityDown != nil && b.Heartbeat.Status != 1 { + priority = q.PriorityDown + } + + okResp, errResp := h.app.SendMessage(g, ctx, q.UserID, q.KeyToken, channel, &title, &content, priority, nil, timestamp, q.SenderName) + if errResp != nil { + return *errResp + } + + return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{ + MessageID: okResp.Message.MessageID, + })) +} diff --git a/scnserver/api/handler/message.go b/scnserver/api/handler/message.go index 758cdf4..b1f6395 100644 --- a/scnserver/api/handler/message.go +++ b/scnserver/api/handler/message.go @@ -2,23 +2,14 @@ package handler import ( "blackforestbytes.com/simplecloudnotifier/api/apierr" - hl "blackforestbytes.com/simplecloudnotifier/api/apihighlight" "blackforestbytes.com/simplecloudnotifier/api/ginresp" primarydb "blackforestbytes.com/simplecloudnotifier/db/impl/primary" "blackforestbytes.com/simplecloudnotifier/logic" "blackforestbytes.com/simplecloudnotifier/models" - "database/sql" - "errors" - "fmt" "github.com/gin-gonic/gin" - "github.com/rs/zerolog/log" "gogs.mikescher.com/BlackForestBytes/goext/dataext" "gogs.mikescher.com/BlackForestBytes/goext/langext" - "gogs.mikescher.com/BlackForestBytes/goext/mathext" - "gogs.mikescher.com/BlackForestBytes/goext/timeext" "net/http" - "strings" - "time" ) type SendMessageResponse struct { @@ -96,7 +87,7 @@ func (h MessageHandler) SendMessage(g *gin.Context) ginresp.HTTPResponse { // query has highest prio, then form, then json data := dataext.ObjectMerge(dataext.ObjectMerge(b, f), q) - okResp, errResp := h.sendMessageInternal(g, ctx, data.UserID, data.KeyToken, data.Channel, data.Title, data.Content, data.Priority, data.UserMessageID, data.SendTimestamp, data.SenderName) + okResp, errResp := h.app.SendMessage(g, ctx, data.UserID, data.KeyToken, data.Channel, data.Title, data.Content, data.Priority, data.UserMessageID, data.SendTimestamp, data.SenderName) if errResp != nil { return *errResp } else { @@ -114,284 +105,3 @@ func (h MessageHandler) SendMessage(g *gin.Context) ginresp.HTTPResponse { })) } } - -func (h MessageHandler) sendMessageInternal(g *gin.Context, ctx *logic.AppContext, UserID *models.UserID, Key *string, Channel *string, Title *string, Content *string, Priority *int, UserMessageID *string, SendTimestamp *float64, SenderName *string) (*SendMessageResponse, *ginresp.HTTPResponse) { - if Title != nil { - Title = langext.Ptr(strings.TrimSpace(*Title)) - } - if UserMessageID != nil { - UserMessageID = langext.Ptr(strings.TrimSpace(*UserMessageID)) - } - - if UserID == nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.MISSING_UID, hl.USER_ID, "Missing parameter [[user_id]]", nil)) - } - if Key == nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.MISSING_TOK, hl.USER_KEY, "Missing parameter [[key]]", nil)) - } - if Title == nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.MISSING_TITLE, hl.TITLE, "Missing parameter [[title]]", nil)) - } - if Priority != nil && (*Priority != 0 && *Priority != 1 && *Priority != 2) { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.INVALID_PRIO, hl.PRIORITY, "Invalid priority", nil)) - } - if len(*Title) == 0 { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.NO_TITLE, hl.TITLE, "No title specified", nil)) - } - - user, err := h.database.GetUser(ctx, *UserID) - if errors.Is(err, sql.ErrNoRows) { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found", err)) - } - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query user", err)) - } - - channelDisplayName := user.DefaultChannel() - channelInternalName := user.DefaultChannel() - if Channel != nil { - channelDisplayName = h.app.NormalizeChannelDisplayName(*Channel) - channelInternalName = h.app.NormalizeChannelInternalName(*Channel) - } - - if len(*Title) > user.MaxTitleLength() { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.TITLE_TOO_LONG, hl.TITLE, fmt.Sprintf("Title too long (max %d characters)", user.MaxTitleLength()), nil)) - } - if Content != nil && len(*Content) > user.MaxContentLength() { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CONTENT_TOO_LONG, hl.CONTENT, fmt.Sprintf("Content too long (%d characters; max := %d characters)", len(*Content), user.MaxContentLength()), nil)) - } - if len(channelDisplayName) > user.MaxChannelNameLength() { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_TOO_LONG, hl.CHANNEL, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil)) - } - if len(strings.TrimSpace(channelDisplayName)) == 0 { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_NAME_EMPTY, hl.CHANNEL, fmt.Sprintf("Channel displayname cannot be empty"), nil)) - } - if len(channelInternalName) > user.MaxChannelNameLength() { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_TOO_LONG, hl.CHANNEL, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil)) - } - if len(strings.TrimSpace(channelInternalName)) == 0 { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_NAME_EMPTY, hl.CHANNEL, fmt.Sprintf("Channel internalname cannot be empty"), nil)) - } - if SenderName != nil && len(*SenderName) > user.MaxSenderNameLength() { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.SENDERNAME_TOO_LONG, hl.SENDER_NAME, fmt.Sprintf("SenderName too long (max %d characters)", user.MaxSenderNameLength()), nil)) - } - if UserMessageID != nil && len(*UserMessageID) > user.MaxUserMessageIDLength() { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.USR_MSG_ID_TOO_LONG, hl.USER_MESSAGE_ID, fmt.Sprintf("MessageID too long (max %d characters)", user.MaxUserMessageIDLength()), nil)) - } - if SendTimestamp != nil && mathext.Abs(*SendTimestamp-float64(time.Now().Unix())) > timeext.FromHours(user.MaxTimestampDiffHours()).Seconds() { - return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.TIMESTAMP_OUT_OF_RANGE, hl.NONE, fmt.Sprintf("The timestamp mus be within %d hours of now()", user.MaxTimestampDiffHours()), nil)) - } - - if UserMessageID != nil { - msg, err := h.database.GetMessageByUserMessageID(ctx, *UserMessageID) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query existing message", err)) - } - if msg != nil { - - existingCompID, _, err := h.database.ConvertToCompatID(ctx, msg.MessageID.String()) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query compat-id", err)) - } - - if existingCompID == nil { - v, err := h.database.CreateCompatID(ctx, "messageid", msg.MessageID.String()) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create compat-id", err)) - } - existingCompID = &v - } - - //the found message can be deleted (!), but we still return NO_ERROR here... - return &SendMessageResponse{ - User: user, - Message: *msg, - MessageIsOld: true, - CompatMessageID: *existingCompID, - }, nil - } - } - - if user.QuotaRemainingToday() <= 0 { - return nil, langext.Ptr(ginresp.SendAPIError(g, 403, apierr.QUOTA_REACHED, hl.NONE, fmt.Sprintf("Daily quota reached (%d)", user.QuotaPerDay()), nil)) - } - - channel, err := h.app.GetOrCreateChannel(ctx, *UserID, channelDisplayName, channelInternalName) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query/create (owned) channel", err)) - } - - keytok, permResp := ctx.CheckPermissionSend(channel, *Key) - if permResp != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 401, apierr.USER_AUTH_FAILED, hl.USER_KEY, "You are not authorized for this action", nil)) - } - - var sendTimestamp *time.Time = nil - if SendTimestamp != nil { - sendTimestamp = langext.Ptr(timeext.UnixFloatSeconds(*SendTimestamp)) - } - - priority := langext.Coalesce(Priority, user.DefaultPriority()) - - clientIP := g.ClientIP() - - msg, err := h.database.CreateMessage(ctx, *UserID, channel, sendTimestamp, *Title, Content, priority, UserMessageID, clientIP, SenderName, keytok.KeyTokenID) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create message in db", err)) - } - - compatMsgID, err := h.database.CreateCompatID(ctx, "messageid", msg.MessageID.String()) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create compat-id", err)) - } - - subFilter := models.SubscriptionFilter{ChannelID: langext.Ptr([]models.ChannelID{channel.ChannelID}), Confirmed: langext.PTrue} - activeSubscriptions, err := h.database.ListSubscriptions(ctx, subFilter) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query subscriptions", err)) - } - - err = h.database.IncUserMessageCounter(ctx, &user) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc user msg-counter", err)) - } - - err = h.database.IncChannelMessageCounter(ctx, &channel) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc channel msg-counter", err)) - } - - err = h.database.IncKeyTokenMessageCounter(ctx, keytok) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc token msg-counter", err)) - } - - log.Info().Msg(fmt.Sprintf("Sending new notification %s for user %s", msg.MessageID, UserID)) - - for _, sub := range activeSubscriptions { - clients, err := h.database.ListClients(ctx, sub.SubscriberUserID) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query clients", err)) - } - - for _, client := range clients { - - isCompatClient, err := h.database.IsCompatClient(ctx, client.ClientID) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query compat_clients", err)) - } - - var titleOverride *string = nil - var msgidOverride *string = nil - if isCompatClient { - titleOverride = langext.Ptr(h.app.CompatizeMessageTitle(ctx, msg)) - msgidOverride = langext.Ptr(fmt.Sprintf("%d", compatMsgID)) - } - - fcmDelivID, err := h.app.DeliverMessage(ctx, client, msg, titleOverride, msgidOverride) - if err != nil { - _, err = h.database.CreateRetryDelivery(ctx, client, msg) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create delivery", err)) - } - } else { - _, err = h.database.CreateSuccessDelivery(ctx, client, msg, fcmDelivID) - if err != nil { - return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create delivery", err)) - } - } - - } - } - - return &SendMessageResponse{ - User: user, - Message: msg, - MessageIsOld: false, - CompatMessageID: compatMsgID, - }, nil -} - -// UptimeKumaWebHook swaggerdoc -// -// @Summary Send a new message -// @Description All parameter can be set via query-parameter or the json body. Only UserID, UserKey and Title are required -// @Tags External -// -// @Param query_data query handler.UptimeKumaWebHook.query false " " -// @Param post_body body handler.UptimeKumaWebHook.uptimeKumaWebhookBody false " " -// -// @Success 200 {object} any -// @Failure 400 {object} ginresp.apiError -// @Failure 401 {object} ginresp.apiError "The user_id was not found or the user_key is wrong" -// @Failure 403 {object} ginresp.apiError "The user has exceeded its daily quota - wait 24 hours or upgrade your account" -// @Failure 500 {object} ginresp.apiError "An internal server error occurred - try again later" -// -// @Router /webhook/uptime-kuma [POST] -func (h MessageHandler) UptimeKumaWebHook(g *gin.Context) ginresp.HTTPResponse { - type query struct { - UserID *models.UserID `form:"user_id" example:"7725"` - KeyToken *string `form:"key" example:"P3TNH8mvv14fm"` - } - - type uptimeKumaWebhookBody struct { - Heartbeat *struct { - Time string `json:"time"` - Status int `json:"status"` - Msg string `json:"msg"` - Timezone string `json:"timezone"` - TimezoneOffset string `json:"timezoneOffset"` - LocalDateTime string `json:"localDateTime"` - } `json:"heartbeat"` - Monitor *struct { - Name string `json:"name"` - Url *string `json:"url"` - } `json:"monitor"` - Msg string `json:"msg"` - } - - var b uptimeKumaWebhookBody - var q query - - ctx, httpErr := h.app.StartRequest(g, nil, &q, &b, nil) - if httpErr != nil { - return *httpErr - } - defer ctx.Cancel() - - var title = "" - - var content = "" - content += fmt.Sprintf("%v\n", b.Msg) - if b.Monitor != nil { - content += fmt.Sprintf("%v\n", b.Monitor.Name) - if b.Monitor.Url != nil { - content += fmt.Sprintf("url: %v\n", *b.Monitor.Url) - } - - if b.Heartbeat != nil { - statusString := "down" - - if b.Heartbeat.Status == 1 { - statusString = "up" - } - title = fmt.Sprintf("%v %v!", b.Monitor.Name, statusString) - } - - } - - if b.Heartbeat != nil { - content += "\n===== Heartbeat ======\n" - content += fmt.Sprintf("msg: %v\n", b.Heartbeat.Msg) - content += fmt.Sprintf("timestamp: %v\n", b.Heartbeat.Time) - content += fmt.Sprintf("timezone: %v\n", b.Heartbeat.Timezone) - content += fmt.Sprintf("timezone offset: %v\n", b.Heartbeat.TimezoneOffset) - content += fmt.Sprintf("local date time: %v\n", b.Heartbeat.TimezoneOffset) - } - okResp, errResp := h.sendMessageInternal(g, ctx, q.UserID, q.KeyToken, nil, &title, &content, langext.Ptr(1), nil, nil, nil) - - if errResp != nil { - return *errResp - } - return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, okResp)) -} diff --git a/scnserver/api/router.go b/scnserver/api/router.go index c60ce62..449ec12 100644 --- a/scnserver/api/router.go +++ b/scnserver/api/router.go @@ -16,22 +16,24 @@ import ( type Router struct { app *logic.Application - commonHandler handler.CommonHandler - compatHandler handler.CompatHandler - websiteHandler handler.WebsiteHandler - apiHandler handler.APIHandler - messageHandler handler.MessageHandler + commonHandler handler.CommonHandler + compatHandler handler.CompatHandler + websiteHandler handler.WebsiteHandler + apiHandler handler.APIHandler + messageHandler handler.MessageHandler + externalHandler handler.ExternalHandler } func NewRouter(app *logic.Application) *Router { return &Router{ app: app, - commonHandler: handler.NewCommonHandler(app), - compatHandler: handler.NewCompatHandler(app), - websiteHandler: handler.NewWebsiteHandler(app), - apiHandler: handler.NewAPIHandler(app), - messageHandler: handler.NewMessageHandler(app), + commonHandler: handler.NewCommonHandler(app), + compatHandler: handler.NewCompatHandler(app), + websiteHandler: handler.NewWebsiteHandler(app), + apiHandler: handler.NewAPIHandler(app), + messageHandler: handler.NewMessageHandler(app), + externalHandler: handler.NewExternalHandler(app), } } @@ -162,9 +164,9 @@ func (r *Router) Init(e *gin.Engine) error { { sendAPI.POST("/", r.Wrap(r.messageHandler.SendMessage)) sendAPI.POST("/send", r.Wrap(r.messageHandler.SendMessage)) - sendAPI.POST("/send.php", r.Wrap(r.messageHandler.SendMessageCompat)) + sendAPI.POST("/send.php", r.Wrap(r.compatHandler.SendMessage)) - sendAPI.POST("/webhook/uptime-kuma", r.Wrap(r.messageHandler.UptimeKumaWebHook)) + sendAPI.POST("/external/v1/uptime-kuma", r.Wrap(r.externalHandler.UptimeKuma)) } diff --git a/scnserver/logic/message.go b/scnserver/logic/message.go new file mode 100644 index 0000000..5f98765 --- /dev/null +++ b/scnserver/logic/message.go @@ -0,0 +1,222 @@ +package logic + +import ( + "blackforestbytes.com/simplecloudnotifier/api/apierr" + hl "blackforestbytes.com/simplecloudnotifier/api/apihighlight" + "blackforestbytes.com/simplecloudnotifier/api/ginresp" + "blackforestbytes.com/simplecloudnotifier/models" + "database/sql" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + "gogs.mikescher.com/BlackForestBytes/goext/langext" + "gogs.mikescher.com/BlackForestBytes/goext/mathext" + "gogs.mikescher.com/BlackForestBytes/goext/timeext" + "strings" + "time" +) + +type SendMessageResponse struct { + User models.User + Message models.Message + MessageIsOld bool + CompatMessageID int64 +} + +func (app *Application) SendMessage(g *gin.Context, ctx *AppContext, UserID *models.UserID, Key *string, Channel *string, Title *string, Content *string, Priority *int, UserMessageID *string, SendTimestamp *float64, SenderName *string) (*SendMessageResponse, *ginresp.HTTPResponse) { + if Title != nil { + Title = langext.Ptr(strings.TrimSpace(*Title)) + } + if UserMessageID != nil { + UserMessageID = langext.Ptr(strings.TrimSpace(*UserMessageID)) + } + + if UserID == nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.MISSING_UID, hl.USER_ID, "Missing parameter [[user_id]]", nil)) + } + if Key == nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.MISSING_TOK, hl.USER_KEY, "Missing parameter [[key]]", nil)) + } + if Title == nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.MISSING_TITLE, hl.TITLE, "Missing parameter [[title]]", nil)) + } + if Priority != nil && (*Priority != 0 && *Priority != 1 && *Priority != 2) { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.INVALID_PRIO, hl.PRIORITY, "Invalid priority", nil)) + } + if len(*Title) == 0 { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.NO_TITLE, hl.TITLE, "No title specified", nil)) + } + + user, err := app.Database.Primary.GetUser(ctx, *UserID) + if errors.Is(err, sql.ErrNoRows) { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.USER_NOT_FOUND, hl.USER_ID, "User not found", err)) + } + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query user", err)) + } + + channelDisplayName := user.DefaultChannel() + channelInternalName := user.DefaultChannel() + if Channel != nil { + channelDisplayName = app.NormalizeChannelDisplayName(*Channel) + channelInternalName = app.NormalizeChannelInternalName(*Channel) + } + + if len(*Title) > user.MaxTitleLength() { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.TITLE_TOO_LONG, hl.TITLE, fmt.Sprintf("Title too long (max %d characters)", user.MaxTitleLength()), nil)) + } + if Content != nil && len(*Content) > user.MaxContentLength() { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CONTENT_TOO_LONG, hl.CONTENT, fmt.Sprintf("Content too long (%d characters; max := %d characters)", len(*Content), user.MaxContentLength()), nil)) + } + if len(channelDisplayName) > user.MaxChannelNameLength() { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_TOO_LONG, hl.CHANNEL, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil)) + } + if len(strings.TrimSpace(channelDisplayName)) == 0 { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_NAME_EMPTY, hl.CHANNEL, fmt.Sprintf("Channel displayname cannot be empty"), nil)) + } + if len(channelInternalName) > user.MaxChannelNameLength() { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_TOO_LONG, hl.CHANNEL, fmt.Sprintf("Channel too long (max %d characters)", user.MaxChannelNameLength()), nil)) + } + if len(strings.TrimSpace(channelInternalName)) == 0 { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.CHANNEL_NAME_EMPTY, hl.CHANNEL, fmt.Sprintf("Channel internalname cannot be empty"), nil)) + } + if SenderName != nil && len(*SenderName) > user.MaxSenderNameLength() { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.SENDERNAME_TOO_LONG, hl.SENDER_NAME, fmt.Sprintf("SenderName too long (max %d characters)", user.MaxSenderNameLength()), nil)) + } + if UserMessageID != nil && len(*UserMessageID) > user.MaxUserMessageIDLength() { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.USR_MSG_ID_TOO_LONG, hl.USER_MESSAGE_ID, fmt.Sprintf("MessageID too long (max %d characters)", user.MaxUserMessageIDLength()), nil)) + } + if SendTimestamp != nil && mathext.Abs(*SendTimestamp-float64(time.Now().Unix())) > timeext.FromHours(user.MaxTimestampDiffHours()).Seconds() { + return nil, langext.Ptr(ginresp.SendAPIError(g, 400, apierr.TIMESTAMP_OUT_OF_RANGE, hl.NONE, fmt.Sprintf("The timestamp mus be within %d hours of now()", user.MaxTimestampDiffHours()), nil)) + } + + if UserMessageID != nil { + msg, err := app.Database.Primary.GetMessageByUserMessageID(ctx, *UserMessageID) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query existing message", err)) + } + if msg != nil { + + existingCompID, _, err := app.Database.Primary.ConvertToCompatID(ctx, msg.MessageID.String()) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query compat-id", err)) + } + + if existingCompID == nil { + v, err := app.Database.Primary.CreateCompatID(ctx, "messageid", msg.MessageID.String()) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create compat-id", err)) + } + existingCompID = &v + } + + //the found message can be deleted (!), but we still return NO_ERROR here... + return &SendMessageResponse{ + User: user, + Message: *msg, + MessageIsOld: true, + CompatMessageID: *existingCompID, + }, nil + } + } + + if user.QuotaRemainingToday() <= 0 { + return nil, langext.Ptr(ginresp.SendAPIError(g, 403, apierr.QUOTA_REACHED, hl.NONE, fmt.Sprintf("Daily quota reached (%d)", user.QuotaPerDay()), nil)) + } + + channel, err := app.GetOrCreateChannel(ctx, *UserID, channelDisplayName, channelInternalName) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query/create (owned) channel", err)) + } + + keytok, permResp := ctx.CheckPermissionSend(channel, *Key) + if permResp != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 401, apierr.USER_AUTH_FAILED, hl.USER_KEY, "You are not authorized for this action", nil)) + } + + var sendTimestamp *time.Time = nil + if SendTimestamp != nil { + sendTimestamp = langext.Ptr(timeext.UnixFloatSeconds(*SendTimestamp)) + } + + priority := langext.Coalesce(Priority, user.DefaultPriority()) + + clientIP := g.ClientIP() + + msg, err := app.Database.Primary.CreateMessage(ctx, *UserID, channel, sendTimestamp, *Title, Content, priority, UserMessageID, clientIP, SenderName, keytok.KeyTokenID) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create message in db", err)) + } + + compatMsgID, err := app.Database.Primary.CreateCompatID(ctx, "messageid", msg.MessageID.String()) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create compat-id", err)) + } + + subFilter := models.SubscriptionFilter{ChannelID: langext.Ptr([]models.ChannelID{channel.ChannelID}), Confirmed: langext.PTrue} + activeSubscriptions, err := app.Database.Primary.ListSubscriptions(ctx, subFilter) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query subscriptions", err)) + } + + err = app.Database.Primary.IncUserMessageCounter(ctx, &user) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc user msg-counter", err)) + } + + err = app.Database.Primary.IncChannelMessageCounter(ctx, &channel) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc channel msg-counter", err)) + } + + err = app.Database.Primary.IncKeyTokenMessageCounter(ctx, keytok) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to inc token msg-counter", err)) + } + + log.Info().Msg(fmt.Sprintf("Sending new notification %s for user %s", msg.MessageID, UserID)) + + for _, sub := range activeSubscriptions { + clients, err := app.Database.Primary.ListClients(ctx, sub.SubscriberUserID) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query clients", err)) + } + + for _, client := range clients { + + isCompatClient, err := app.Database.Primary.IsCompatClient(ctx, client.ClientID) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query compat_clients", err)) + } + + var titleOverride *string = nil + var msgidOverride *string = nil + if isCompatClient { + titleOverride = langext.Ptr(app.CompatizeMessageTitle(ctx, msg)) + msgidOverride = langext.Ptr(fmt.Sprintf("%d", compatMsgID)) + } + + fcmDelivID, err := app.DeliverMessage(ctx, client, msg, titleOverride, msgidOverride) + if err != nil { + _, err = app.Database.Primary.CreateRetryDelivery(ctx, client, msg) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create delivery", err)) + } + } else { + _, err = app.Database.Primary.CreateSuccessDelivery(ctx, client, msg, fcmDelivID) + if err != nil { + return nil, langext.Ptr(ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to create delivery", err)) + } + } + + } + } + + return &SendMessageResponse{ + User: user, + Message: msg, + MessageIsOld: false, + CompatMessageID: compatMsgID, + }, nil +} diff --git a/scnserver/test/send_test.go b/scnserver/test/send_test.go index a2dcf78..c2f89c2 100644 --- a/scnserver/test/send_test.go +++ b/scnserver/test/send_test.go @@ -1780,43 +1780,3 @@ func TestSendWithPermissionSendKey(t *testing.T) { func TestSendDeliveryRetry(t *testing.T) { t.SkipNow() //TODO } - -func TestUptimeKuma(t *testing.T) { - ws, baseUrl, stop := tt.StartSimpleWebserver(t) - defer stop() - - pusher := ws.Pusher.(*push.TestSink) - - r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/v2/users", gin.H{ - "agent_model": "DUMMY_PHONE", - "agent_version": "4X", - "client_type": "ANDROID", - "fcm_token": "DUMMY_FCM", - }) - - uid := r0["user_id"].(string) - admintok := r0["admin_key"].(string) - sendtok := r0["send_key"].(string) - - suffix := fmt.Sprintf("/webhook/uptime-kuma?user_id=%v&key=%v", uid, sendtok) - _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ - "msg": "Uptime Kuma failed with 503!", - "heartbeat": gin.H{ - "status": 0, - }, - "monitor": gin.H{ - "name": "Test-Kuma", - }, - }) - - tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) - tt.AssertStrRepEqual(t, "msg.title", "Test-Kuma down!", pusher.Last().Message.Title) - - type mglist struct { - Messages []gin.H `json:"messages"` - } - - msgList1 := tt.RequestAuthGet[mglist](t, admintok, baseUrl, "/api/v2/messages") - tt.AssertEqual(t, "len(messages)", 1, len(msgList1.Messages)) - tt.AssertStrRepEqual(t, "msg.title", "Test-Kuma down!", msgList1.Messages[0]["title"]) -} diff --git a/scnserver/test/uptimekuma_test.go b/scnserver/test/uptimekuma_test.go new file mode 100644 index 0000000..39b8b85 --- /dev/null +++ b/scnserver/test/uptimekuma_test.go @@ -0,0 +1,447 @@ +package test + +import ( + "blackforestbytes.com/simplecloudnotifier/push" + tt "blackforestbytes.com/simplecloudnotifier/test/util" + "fmt" + "github.com/gin-gonic/gin" + "testing" + "time" +) + +func TestUptimeKumaDown(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com", + "heartbeat": gin.H{ + "status": 0, + "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.title", "Monitor test went down!", pusher.Last().Message.Title) + + type mglist struct { + Messages []gin.H `json:"messages"` + } + + msgList1 := tt.RequestAuthGet[mglist](t, data.AdminKey, baseUrl, "/api/v2/messages") + tt.AssertEqual(t, "len(messages)", 1, len(msgList1.Messages)) + tt.AssertStrRepEqual(t, "msg.title", "Monitor test went down!", msgList1.Messages[0]["title"]) + tt.AssertStrRepEqual(t, "msg.content", "getaddrinfo ENOTFOUND exampleasdsda.com", msgList1.Messages[0]["content"]) +} + +func TestUptimeKumaUp(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [✅ Up] 200 - OK", + "heartbeat": gin.H{ + "status": 1, + "msg": "200 - OK", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.title", "Monitor test is back online", pusher.Last().Message.Title) + + type mglist struct { + Messages []gin.H `json:"messages"` + } + + msgList1 := tt.RequestAuthGet[mglist](t, data.AdminKey, baseUrl, "/api/v2/messages") + tt.AssertEqual(t, "len(messages)", 1, len(msgList1.Messages)) + tt.AssertStrRepEqual(t, "msg.title", "Monitor test is back online", msgList1.Messages[0]["title"]) + tt.AssertStrRepEqual(t, "msg.content", "200 - OK", msgList1.Messages[0]["content"]) +} + +func TestUptimeKumaFullDown(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + ts := time.Now().Add(-time.Hour).Format("2006-01-02 15:04:05") + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, tt.RawJSON{ContentType: "application/json", Body: `{"heartbeat":{"monitorID":89,"status":0,"time":"` + ts + `","msg":"timeout of 16000ms exceeded","important":true,"duration":36,"timezone":"Europe/Berlin","timezoneOffset":"+02:00","localDateTime":"` + ts + `"},"monitor":{"id":89,"name":"test","description":null,"pathName":"test","parent":null,"childrenIDs":[],"url":"https://exampleXYZ.com","method":"GET","hostname":null,"port":null,"maxretries":1,"weight":2000,"active":true,"forceInactive":false,"type":"http","interval":20,"retryInterval":20,"resendInterval":0,"keyword":null,"expiryNotification":false,"ignoreTls":false,"upsideDown":false,"packetSize":56,"maxredirects":10,"accepted_statuscodes":["200-299"],"dns_resolve_type":"A","dns_resolve_server":"1.1.1.1","dns_last_result":null,"docker_container":"","docker_host":null,"proxyId":null,"notificationIDList":{"2":true},"tags":[],"maintenance":false,"mqttTopic":"","mqttSuccessMessage":"","databaseQuery":null,"authMethod":null,"grpcUrl":null,"grpcProtobuf":null,"grpcMethod":null,"grpcServiceName":null,"grpcEnableTls":false,"radiusCalledStationId":null,"radiusCallingStationId":null,"game":null,"httpBodyEncoding":"json","includeSensitiveData":false},"msg":"[test] [🔴 Down] timeout of 16000ms exceeded"}`}) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.title", "Monitor test went down!", pusher.Last().Message.Title) + tt.AssertStrRepEqual(t, "msg.title", "timeout of 16000ms exceeded", pusher.Last().Message.Content) + + type mglist struct { + Messages []gin.H `json:"messages"` + } + + msgList1 := tt.RequestAuthGet[mglist](t, data.AdminKey, baseUrl, "/api/v2/messages") + tt.AssertEqual(t, "len(messages)", 1, len(msgList1.Messages)) + tt.AssertStrRepEqual(t, "msg.title", "Monitor test went down!", msgList1.Messages[0]["title"]) + tt.AssertStrRepEqual(t, "msg.content", "timeout of 16000ms exceeded", msgList1.Messages[0]["content"]) +} + +func TestUptimeKumaFullUp(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + ts := time.Now().Add(-time.Hour).Format("2006-01-02 15:04:05") + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, tt.RawJSON{ContentType: "application/json", Body: `{"heartbeat":{"monitorID":89,"status":1,"time":"` + ts + `","msg":"200 - OK","ping":55,"important":true,"duration":41,"timezone":"Europe/Berlin","timezoneOffset":"+02:00","localDateTime":"` + ts + `"},"monitor":{"id":89,"name":"test","description":null,"pathName":"test","parent":null,"childrenIDs":[],"url":"https://example.com","method":"GET","hostname":null,"port":null,"maxretries":1,"weight":2000,"active":true,"forceInactive":false,"type":"http","interval":20,"retryInterval":20,"resendInterval":0,"keyword":null,"expiryNotification":false,"ignoreTls":false,"upsideDown":false,"packetSize":56,"maxredirects":10,"accepted_statuscodes":["200-299"],"dns_resolve_type":"A","dns_resolve_server":"1.1.1.1","dns_last_result":null,"docker_container":"","docker_host":null,"proxyId":null,"notificationIDList":{"2":true},"tags":[],"maintenance":false,"mqttTopic":"","mqttSuccessMessage":"","databaseQuery":null,"authMethod":null,"grpcUrl":null,"grpcProtobuf":null,"grpcMethod":null,"grpcServiceName":null,"grpcEnableTls":false,"radiusCalledStationId":null,"radiusCallingStationId":null,"game":null,"httpBodyEncoding":"json","includeSensitiveData":false},"msg":"[test] [✅ Up] 200 - OK"}`}) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.title", "Monitor test is back online", pusher.Last().Message.Title) + + type mglist struct { + Messages []gin.H `json:"messages"` + } + + msgList1 := tt.RequestAuthGet[mglist](t, data.AdminKey, baseUrl, "/api/v2/messages") + tt.AssertEqual(t, "len(messages)", 1, len(msgList1.Messages)) + tt.AssertStrRepEqual(t, "msg.title", "Monitor test is back online", msgList1.Messages[0]["title"]) + tt.AssertStrRepEqual(t, "msg.content", "200 - OK", msgList1.Messages[0]["content"]) +} + +func TestUptimeKumaChannelNone(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com", + "heartbeat": gin.H{ + "status": 0, + "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.channel", "main", pusher.Last().Message.ChannelInternalName) +} + +func TestUptimeKumaChannelSingle(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&channel=CTEST", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com", + "heartbeat": gin.H{ + "status": 0, + "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.channel", "CTEST", pusher.Last().Message.ChannelInternalName) +} + +func TestUptimeKumaChannelAllDown(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&channel=CTEST&channel_up=CTEST_UP&channel_down=CTEST_DOWN", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com", + "heartbeat": gin.H{ + "status": 0, + "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.channel", "CTEST_DOWN", pusher.Last().Message.ChannelInternalName) +} + +func TestUptimeKumaChannelSpecDown(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&channel_up=CTEST_UP&channel_down=CTEST_DOWN", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com", + "heartbeat": gin.H{ + "status": 0, + "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.channel", "CTEST_DOWN", pusher.Last().Message.ChannelInternalName) +} + +func TestUptimeKumaChannelAllUp(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&channel=CTEST&channel_up=CTEST_UP&channel_down=CTEST_DOWN", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [✅ Up] 200 - OK", + "heartbeat": gin.H{ + "status": 1, + "msg": "200 - OK", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.channel", "CTEST_UP", pusher.Last().Message.ChannelInternalName) +} + +func TestUptimeKumaChannelSpecUp(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&channel_up=CTEST_UP&channel_down=CTEST_DOWN", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [✅ Up] 200 - OK", + "heartbeat": gin.H{ + "status": 1, + "msg": "200 - OK", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.channel", "CTEST_UP", pusher.Last().Message.ChannelInternalName) +} + +func TestUptimeKumaPriorityNone(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com", + "heartbeat": gin.H{ + "status": 0, + "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.channel", 1, pusher.Last().Message.Priority) +} + +func TestUptimeKumaPrioritySingle(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix0 := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&priority=0", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix0, gin.H{ + "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com", + "heartbeat": gin.H{ + "status": 0, + "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.prio", 0, pusher.Last().Message.Priority) + + suffix1 := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&priority=1", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix1, gin.H{ + "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com", + "heartbeat": gin.H{ + "status": 0, + "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 2, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.prio", 1, pusher.Last().Message.Priority) + + suffix2 := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&priority=2", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix2, gin.H{ + "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com", + "heartbeat": gin.H{ + "status": 0, + "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 3, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.prio", 2, pusher.Last().Message.Priority) +} + +func TestUptimeKumaPriorityAllDown(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&priority=1&priority_up=2&priority_down=0", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com", + "heartbeat": gin.H{ + "status": 0, + "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.prio", 0, pusher.Last().Message.Priority) +} + +func TestUptimeKumaPrioritySpecDown(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&priority_up=2&priority_down=0", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [🔴 Down] getaddrinfo ENOTFOUND exampleasdsda.com", + "heartbeat": gin.H{ + "status": 0, + "msg": "getaddrinfo ENOTFOUND exampleasdsda.com", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.prio", 0, pusher.Last().Message.Priority) +} + +func TestUptimeKumaPriorityAllUp(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&priority=1&priority_up=2&priority_down=0", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [✅ Up] 200 - OK", + "heartbeat": gin.H{ + "status": 1, + "msg": "200 - OK", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.prio", 2, pusher.Last().Message.Priority) +} + +func TestUptimeKumaPrioritySpecUp(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitSingleData(t, ws) + + pusher := ws.Pusher.(*push.TestSink) + + suffix := fmt.Sprintf("/external/v1/uptime-kuma?user_id=%v&key=%v&priority_up=2&priority_down=0", data.UID, data.SendKey) + _ = tt.RequestPost[gin.H](t, baseUrl, suffix, gin.H{ + "msg": "[test] [✅ Up] 200 - OK", + "heartbeat": gin.H{ + "status": 1, + "msg": "200 - OK", + }, + "monitor": gin.H{ + "name": "test", + }, + }) + + tt.AssertEqual(t, "messageCount", 1, len(pusher.Data)) + tt.AssertStrRepEqual(t, "msg.prio", 2, pusher.Last().Message.Priority) +} diff --git a/scnserver/test/util/formData.go b/scnserver/test/util/formData.go index b4b31c8..a8790d8 100644 --- a/scnserver/test/util/formData.go +++ b/scnserver/test/util/formData.go @@ -1,3 +1,8 @@ package util type FormData map[string]string + +type RawJSON struct { + ContentType string + Body string +} diff --git a/scnserver/test/util/requests.go b/scnserver/test/util/requests.go index 82731d3..6316fbf 100644 --- a/scnserver/test/util/requests.go +++ b/scnserver/test/util/requests.go @@ -114,6 +114,9 @@ func RequestAny[TResult any](t *testing.T, akey string, method string, baseURL s } bytesbody = bodybuffer.Bytes() contentType = writer.FormDataContentType() + case RawJSON: + bytesbody = []byte(body.(RawJSON).Body) + contentType = "application/json" default: bjson, err := json.Marshal(body) if err != nil {