handlers.*DiscordHandler.sendSMS   C
last analyzed

Complexity

Conditions 6

Size

Total Lines 115
Code Lines 77

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 77
nop 3
dl 0
loc 115
rs 6.8193
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
package handlers
2
3
import (
4
	"bytes"
5
	"context"
6
	"crypto/ed25519"
7
	"encoding/hex"
8
	"encoding/json"
9
	"fmt"
10
	"os"
11
12
	"github.com/google/uuid"
13
14
	"github.com/NdoleStudio/httpsms/pkg/repositories"
15
	"github.com/NdoleStudio/httpsms/pkg/requests"
16
	"github.com/NdoleStudio/httpsms/pkg/services"
17
	"github.com/NdoleStudio/httpsms/pkg/telemetry"
18
	"github.com/NdoleStudio/httpsms/pkg/validators"
19
	"github.com/davecgh/go-spew/spew"
20
	"github.com/gofiber/fiber/v2"
21
	"github.com/palantir/stacktrace"
22
)
23
24
// DiscordHandler handles discord events
25
type DiscordHandler struct {
26
	handler
27
	logger           telemetry.Logger
28
	tracer           telemetry.Tracer
29
	billingService   *services.BillingService
30
	messageValidator *validators.MessageHandlerValidator
31
	validator        *validators.DiscordHandlerValidator
32
	service          *services.DiscordService
33
	messageService   *services.MessageService
34
}
35
36
// NewDiscordHandler creates a new DiscordHandler
37
func NewDiscordHandler(
38
	logger telemetry.Logger,
39
	tracer telemetry.Tracer,
40
	validator *validators.DiscordHandlerValidator,
41
	service *services.DiscordService,
42
	messageService *services.MessageService,
43
	billingService *services.BillingService,
44
	messageValidator *validators.MessageHandlerValidator,
45
) (h *DiscordHandler) {
46
	return &DiscordHandler{
47
		logger:           logger.WithService(fmt.Sprintf("%T", h)),
48
		tracer:           tracer,
49
		validator:        validator,
50
		service:          service,
51
		messageService:   messageService,
52
		billingService:   billingService,
53
		messageValidator: messageValidator,
54
	}
55
}
56
57
// RegisterRoutes registers the routes for the MessageHandler
58
func (h *DiscordHandler) RegisterRoutes(app *fiber.App, authMiddleware fiber.Handler, middlewares ...fiber.Handler) {
59
	router := app.Group("discord")
60
	router.Post("/event", h.computeRoute(middlewares, h.Event)...)
61
62
	authRouter := app.Group("v1/discord-integrations")
63
	authRouter.Post("/", h.computeRoute(append(middlewares, authMiddleware), h.Store)...)
64
	authRouter.Get("/", h.computeRoute(append(middlewares, authMiddleware), h.Index)...)
65
	authRouter.Delete("/:discordID", h.computeRoute(append(middlewares, authMiddleware), h.Delete)...)
66
	authRouter.Put("/:discordID", h.computeRoute(append(middlewares, authMiddleware), h.Update)...)
67
}
68
69
// Index returns the discord integrations of a user
70
// @Summary      Get discord integrations of a user
71
// @Description  Get the discord integrations of a user
72
// @Security	 ApiKeyAuth
73
// @Tags         DiscordIntegration
74
// @Accept       json
75
// @Produce      json
76
// @Param        skip		query  int  	false	"number of discord integrations to skip"		minimum(0)
77
// @Param        query		query  string  	false 	"filter discord integrations containing query"
78
// @Param        limit		query  int  	false	"number of discord integrations to return"	minimum(1)	maximum(20)
79
// @Success      200 		{object}	responses.DiscordsResponse
80
// @Failure      400		{object}	responses.BadRequest
81
// @Failure 	 401	    {object}	responses.Unauthorized
82
// @Failure      422		{object}	responses.UnprocessableEntity
83
// @Failure      500		{object}	responses.InternalServerError
84
// @Router       /discord-integrations 	[get]
85
func (h *DiscordHandler) Index(c *fiber.Ctx) error {
86
	ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
87
	defer span.End()
88
89
	var request requests.DiscordIndex
90
	if err := c.QueryParser(&request); err != nil {
91
		msg := fmt.Sprintf("cannot marshall URL [%s] into %T", c.OriginalURL(), request)
92
		ctxLogger.Warn(stacktrace.Propagate(err, msg))
93
		return h.responseBadRequest(c, err)
94
	}
95
96
	if errors := h.validator.ValidateIndex(ctx, request.Sanitize()); len(errors) != 0 {
97
		msg := fmt.Sprintf("validation errors [%s], while fetching discord integrations [%+#v]", spew.Sdump(errors), request)
98
		ctxLogger.Warn(stacktrace.NewError(msg))
99
		return h.responseUnprocessableEntity(c, errors, "validation errors while fetching discord integrations")
100
	}
101
102
	discordIntegrations, err := h.service.Index(ctx, h.userIDFomContext(c), request.ToIndexParams())
103
	if err != nil {
104
		msg := fmt.Sprintf("cannot get discord integrations with params [%+#v]", request)
105
		ctxLogger.Error(stacktrace.Propagate(err, msg))
106
		return h.responseInternalServerError(c)
107
	}
108
109
	return h.responseOK(c, fmt.Sprintf("fetched %d discord %s", len(discordIntegrations), h.pluralize("integration", len(discordIntegrations))), discordIntegrations)
110
}
111
112
// Delete a discord integration
113
// @Summary      Delete discord integration
114
// @Description  Delete a discord integration for a user
115
// @Security	 ApiKeyAuth
116
// @Tags         Webhooks
117
// @Accept       json
118
// @Produce      json
119
// @Param 		 discordID 	path		string 				true 	"ID of the discord integration"	default(32343a19-da5e-4b1b-a767-3298a73703ca)
120
// @Success      204		{object}    responses.NoContent
121
// @Failure      400		{object}	responses.BadRequest
122
// @Failure 	 401    	{object}	responses.Unauthorized
123
// @Failure      422		{object}	responses.UnprocessableEntity
124
// @Failure      500		{object}	responses.InternalServerError
125
// @Router       /discord-integrations/{discordID} [delete]
126
func (h *DiscordHandler) Delete(c *fiber.Ctx) error {
127
	ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
128
	defer span.End()
129
130
	discordID := c.Params("discordID")
131
	if errors := h.validator.ValidateUUID(discordID, "discordID"); len(errors) != 0 {
132
		msg := fmt.Sprintf("validation errors [%s], while deleting discord integration with ID [%s]", spew.Sdump(errors), discordID)
133
		ctxLogger.Warn(stacktrace.NewError(msg))
134
		return h.responseUnprocessableEntity(c, errors, "validation errors while deleting discord integration")
135
	}
136
137
	err := h.service.Delete(ctx, h.userIDFomContext(c), uuid.MustParse(discordID))
138
	if err != nil {
139
		msg := fmt.Sprintf("cannot delete discord integration with ID [%+#v]", discordID)
140
		ctxLogger.Error(stacktrace.Propagate(err, msg))
141
		return h.responseInternalServerError(c)
142
	}
143
144
	return h.responseOK(c, "discord integration deleted successfully", nil)
145
}
146
147
// Update an entities.Discord
148
// @Summary      Update a discord integration
149
// @Description  Update a discord integration for the currently authenticated user
150
// @Security	 ApiKeyAuth
151
// @Tags         DiscordIntegration
152
// @Accept       json
153
// @Produce      json
154
// @Param 		 discordID	path		string 							true 	"ID of the discord integration" 					default(32343a19-da5e-4b1b-a767-3298a73703ca)
155
// @Param        payload   	body 		requests.DiscordUpdate  		true 	"Payload of discord integration to update"
156
// @Success      200 		{object}	responses.DiscordResponse
157
// @Failure      400		{object}	responses.BadRequest
158
// @Failure 	 401    	{object}	responses.Unauthorized
159
// @Failure      422		{object}	responses.UnprocessableEntity
160
// @Failure      500		{object}	responses.InternalServerError
161
// @Router       /discord-integrations/{discordID} 	[put]
162
func (h *DiscordHandler) Update(c *fiber.Ctx) error {
163
	ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
164
	defer span.End()
165
166
	var request requests.DiscordUpdate
167
	if err := c.BodyParser(&request); err != nil {
168
		msg := fmt.Sprintf("cannot marshall params [%s] into [%T]", c.Body(), request)
169
		ctxLogger.Warn(stacktrace.Propagate(err, msg))
170
		return h.responseBadRequest(c, err)
171
	}
172
173
	request.DiscordID = c.Params("discordID")
174
	if errors := h.validator.ValidateUpdate(ctx, request.Sanitize()); len(errors) != 0 {
175
		msg := fmt.Sprintf("validation errors [%s], while updating user [%+#v]", spew.Sdump(errors), request)
176
		ctxLogger.Warn(stacktrace.NewError(msg))
177
		return h.responseUnprocessableEntity(c, errors, "validation errors while updating discord integration")
178
	}
179
180
	user, err := h.service.Update(ctx, request.ToUpdateParams(h.userFromContext(c)))
181
	if err != nil {
182
		msg := fmt.Sprintf("cannot update discord integration with params [%+#v]", request)
183
		ctxLogger.Error(stacktrace.Propagate(err, msg))
184
		return h.responseInternalServerError(c)
185
	}
186
187
	return h.responseOK(c, "discord integration updated successfully", user)
188
}
189
190
// Store an entities.Discord
191
// @Summary      Store discord integration
192
// @Description  Store a discord integration for the authenticated user
193
// @Security	 ApiKeyAuth
194
// @Tags         DiscordIntegration
195
// @Accept       json
196
// @Produce      json
197
// @Param        payload   	body 		requests.DiscordStore  		true "Payload of the discord integration request"
198
// @Success      201 		{object}	responses.DiscordResponse
199
// @Failure      400		{object}	responses.BadRequest
200
// @Failure 	 401	    {object}	responses.Unauthorized
201
// @Failure      422		{object}	responses.UnprocessableEntity
202
// @Failure      500		{object}	responses.InternalServerError
203
// @Router       /discord-integrations [post]
204
func (h *DiscordHandler) Store(c *fiber.Ctx) error {
205
	ctx, span := h.tracer.StartFromFiberCtx(c)
206
	defer span.End()
207
208
	ctxLogger := h.tracer.CtxLogger(h.logger, span)
209
210
	var request requests.DiscordStore
211
	if err := c.BodyParser(&request); err != nil {
212
		msg := fmt.Sprintf("cannot marshall body [%s] into [%T]", c.Body(), request)
213
		ctxLogger.Warn(stacktrace.Propagate(err, msg))
214
		return h.responseBadRequest(c, err)
215
	}
216
217
	if errors := h.validator.ValidateStore(ctx, request.Sanitize()); len(errors) != 0 {
218
		msg := fmt.Sprintf("validation errors [%s], while storing discord integration [%+#v]", spew.Sdump(errors), request)
219
		ctxLogger.Warn(stacktrace.NewError(msg))
220
		return h.responseUnprocessableEntity(c, errors, "validation errors while storing discord integration")
221
	}
222
223
	discordIntegrations, err := h.service.Index(ctx, h.userIDFomContext(c), repositories.IndexParams{Skip: 0, Limit: 1})
224
	if err != nil {
225
		ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot index discord integrations for user [%s]", h.userIDFomContext(c))))
226
		return h.responseInternalServerError(c)
227
	}
228
229
	if len(discordIntegrations) > 0 {
230
		ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("user with ID [%s] wants to create more than 1 discord integration", h.userIDFomContext(c))))
231
		return h.responsePaymentRequired(c, "You can't create more than 1 discord integration contact us to upgrade your account.")
232
	}
233
234
	discordIntegration, err := h.service.Store(ctx, request.ToStoreParams(h.userFromContext(c)))
235
	if err != nil {
236
		msg := fmt.Sprintf("cannot store discord integration with params [%+#v]", request)
237
		ctxLogger.Error(stacktrace.Propagate(err, msg))
238
		return h.responseInternalServerError(c)
239
	}
240
241
	return h.responseCreated(c, "discord integration created successfully", discordIntegration)
242
}
243
244
// Event consumes a discord event
245
// @Summary      Consume a discord event
246
// @Description  Publish a discord event to the registered listeners
247
// @Tags         Discord
248
// @Accept       json
249
// @Produce      json
250
// @Success      204 		{object}	responses.NoContent
251
// @Failure      400		{object}	responses.BadRequest
252
// @Failure 	 401    	{object}	responses.Unauthorized
253
// @Failure      422		{object}	responses.UnprocessableEntity
254
// @Failure      500		{object}	responses.InternalServerError
255
// @Router       /discord/event [post]
256
func (h *DiscordHandler) Event(c *fiber.Ctx) error {
257
	ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
258
	defer span.End()
259
260
	if verified := h.verifyInteraction(ctxLogger, c); !verified {
261
		return h.responseUnauthorized(c)
262
	}
263
264
	var payload map[string]any
265
	if err := json.Unmarshal(c.Body(), &payload); err != nil {
266
		msg := fmt.Sprintf("cannot unmarshall [%s] to [%T]", string(c.Body()), payload)
267
		ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)))
268
		return h.responseBadRequest(c, err)
269
	}
270
271
	ctxLogger.Info(string(c.Body()))
272
273
	if payload["type"].(float64) == 1 {
274
		return c.JSON(fiber.Map{"type": 1})
275
	}
276
277
	if payload["type"].(float64) == 2 {
278
		return h.sendSMS(ctx, c, payload)
279
	}
280
281
	return h.responseBadRequest(c, stacktrace.NewError(fmt.Sprintf("unknown type [%d]", payload["type"])))
282
}
283
284
func (h *DiscordHandler) createRequest(payload map[string]any) requests.MessageSend {
285
	getOption := func(name string) string {
286
		for _, option := range payload["data"].(map[string]any)["options"].([]any) {
287
			if option.(map[string]any)["name"].(string) == name {
288
				return option.(map[string]any)["value"].(string)
289
			}
290
		}
291
		return ""
292
	}
293
	return requests.MessageSend{
294
		From:    getOption("from"),
295
		To:      getOption("to"),
296
		Content: getOption("message"),
297
	}
298
}
299
300
func (h *DiscordHandler) sendSMS(ctx context.Context, c *fiber.Ctx, payload map[string]any) error {
301
	_, span, ctxLogger := h.tracer.StartWithLogger(ctx, h.logger)
302
	defer span.End()
303
304
	discord, err := h.service.GetByServerID(ctx, payload["guild_id"].(string))
305
	if err != nil {
306
		msg := fmt.Sprintf("cannot get discord integration by server ID [%s]", payload["guild_id"].(string))
307
		ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)))
308
		return c.JSON(
309
			fiber.Map{
310
				"type": 4,
311
				"data": fiber.Map{
312
					"content": "**⚠️ error while sending message**",
313
					"embeds": []fiber.Map{
314
						{
315
							"title": "We cannot find the link to your discord server to an account on [httpsms.com](https://httpsms.com/settings).",
316
							"color": 14681092,
317
						},
318
					},
319
				},
320
			},
321
		)
322
	}
323
324
	request := h.createRequest(payload)
325
	messageEmbed := fiber.Map{
326
		"fields": []fiber.Map{
327
			{
328
				"name":   "From:",
329
				"value":  request.From,
330
				"inline": true,
331
			},
332
			{
333
				"name":   "To:",
334
				"value":  request.To,
335
				"inline": true,
336
			},
337
			{
338
				"name":  "Content:",
339
				"value": request.Content,
340
			},
341
		},
342
	}
343
344
	if errors := h.messageValidator.ValidateMessageSend(ctx, discord.UserID, request.Sanitize()); len(errors) != 0 {
345
		msg := fmt.Sprintf("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body())
346
		ctxLogger.Warn(stacktrace.NewError(msg))
347
348
		var embeds []fiber.Map
349
		for _, value := range errors {
350
			embeds = append(embeds, fiber.Map{
351
				"title": value[0],
352
				"color": 14681092,
353
			})
354
		}
355
356
		return c.JSON(
357
			fiber.Map{
358
				"type": 4,
359
				"data": fiber.Map{
360
					"content": "**⚠️ error while sending message**",
361
					"embeds":  append(embeds, messageEmbed),
362
				},
363
			},
364
		)
365
	}
366
367
	if msg := h.billingService.IsEntitled(ctx, discord.UserID); msg != nil {
368
		ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("user with ID [%s] can't send a message", discord.UserID)))
369
		return c.JSON(
370
			fiber.Map{
371
				"type": 4,
372
				"data": fiber.Map{
373
					"content": "**⚠️ error while sending message**",
374
					"embeds": append([]fiber.Map{
375
						{
376
							"title": msg,
377
							"color": 14681092,
378
						},
379
					}, messageEmbed),
380
				},
381
			},
382
		)
383
	}
384
385
	message, err := h.messageService.SendMessage(ctx, request.ToMessageSendParams(discord.UserID, c.OriginalURL()))
386
	if err != nil {
387
		msg := fmt.Sprintf("cannot send message with paylod [%s] from discord server [%s]", c.Body(), discord.ServerID)
388
		ctxLogger.Error(stacktrace.Propagate(err, msg))
389
		return c.JSON(
390
			fiber.Map{
391
				"type": 4,
392
				"data": fiber.Map{
393
					"content": "**Could not send the message⚠️**",
394
					"embeds": append([]fiber.Map{
395
						{
396
							"title": "Internal server error while sending SMS. Please try again later or contact support.",
397
							"color": 14681092,
398
						},
399
					}, messageEmbed),
400
				},
401
			},
402
		)
403
	}
404
405
	messageEmbed["fields"] = append(messageEmbed["fields"].([]fiber.Map), fiber.Map{
406
		"name":  "MessageID:",
407
		"value": message.ID,
408
	})
409
	return c.JSON(
410
		fiber.Map{
411
			"type": 4,
412
			"data": fiber.Map{
413
				"content": "✔ sending sms",
414
				"embeds":  []fiber.Map{messageEmbed},
415
			},
416
		},
417
	)
418
}
419
420
// verifyInteraction implements message verification of the discord interactions api
421
// signing algorithm, as documented here:
422
// https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization
423
func (h *DiscordHandler) verifyInteraction(ctxLogger telemetry.Logger, c *fiber.Ctx) bool {
424
	var msg bytes.Buffer
425
426
	signature := c.Get("X-Signature-Ed25519")
427
	if signature == "" {
428
		ctxLogger.Info("X-Signature-Ed25519 header is empty")
429
		return false
430
	}
431
432
	sig, err := hex.DecodeString(signature)
433
	if err != nil {
434
		ctxLogger.Info(fmt.Sprintf("cannot decode X-Signature-Ed25519 [%s]", signature))
435
		return false
436
	}
437
438
	if len(sig) != ed25519.SignatureSize {
439
		ctxLogger.Info(fmt.Sprintf("invalid signature size [%d]", len(sig)))
440
		return false
441
	}
442
443
	timestamp := c.Get("X-Signature-Timestamp")
444
	if timestamp == "" {
445
		ctxLogger.Info("X-Signature-Timestamp header is empty")
446
		return false
447
	}
448
449
	msg.WriteString(timestamp)
450
	msg.Write(c.Body())
451
452
	key, err := hex.DecodeString(os.Getenv("DISCORD_PUBLIC_KEY"))
453
	if err != nil {
454
		ctxLogger.Error(stacktrace.Propagate(err, "cannot decode DISCORD_PUBLIC_KEY env variable [%s]", os.Getenv("DISCORD_PUBLIC_KEY")))
455
		return false
456
	}
457
458
	return ed25519.Verify(key, msg.Bytes(), sig)
459
}
460