From 20373707bdef93bb5744b43773fa75115aaf5d9b Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 8 Jun 2024 23:26:48 +0300 Subject: [PATCH 001/583] UPdate the algorithm to AES 256 --- web/pages/blog/end-to-end-encryption-to-sms-messages.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/pages/blog/end-to-end-encryption-to-sms-messages.vue b/web/pages/blog/end-to-end-encryption-to-sms-messages.vue index 11299584..729acdd9 100644 --- a/web/pages/blog/end-to-end-encryption-to-sms-messages.vue +++ b/web/pages/blog/end-to-end-encryption-to-sms-messages.vue @@ -32,7 +32,7 @@ AES 265AES 256 encryption algorithm to encrypt and decrypt the messages.

@@ -58,10 +58,10 @@ >

Encrypt your SMS message

- We use the AES-265 encryption algorithm to encrypt the SMS messages. + We use the AES-256 encryption algorithm to encrypt the SMS messages. This algorithm requires a an encryption key which is 256 bits to work around this, we will hash any encryption key you set on the mobile app - using the sha-265 algorithm so that it will always produce a key which + using the sha-256 algorithm so that it will always produce a key which is 256 bits.

From 4cb1c02548749bc58c628f276b1d9c1118e81a78 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 9 Jun 2024 22:14:58 +0300 Subject: [PATCH 002/583] Add ability to bulk delete messages and threads. closes #434 --- api/docs/docs.go | 149 +++++- api/docs/swagger.json | 137 ++++- api/docs/swagger.yaml | 100 +++- api/pkg/di/container.go | 2 +- api/pkg/events/message_api_deleted_event.go | 21 +- api/pkg/handlers/message_handler.go | 47 ++ api/pkg/listeners/message_thread_listener.go | 6 +- .../repositories/gorm_message_repository.go | 86 ++++ api/pkg/repositories/message_repository.go | 6 + api/pkg/repositories/repository.go | 8 +- api/pkg/requests/message_search_request.go | 67 +++ api/pkg/services/message_service.go | 57 ++- api/pkg/services/message_thread_service.go | 36 +- .../validators/message_handler_validator.go | 53 ++ api/pkg/validators/validator.go | 27 + web/components/MessageThreadHeader.vue | 18 + web/models/message.ts | 11 + web/pages/search-messages/index.vue | 482 ++++++++++++++++++ web/plugins/filters.ts | 10 +- web/plugins/veutify.ts | 67 +++ web/store/index.ts | 22 +- 21 files changed, 1375 insertions(+), 37 deletions(-) create mode 100644 api/pkg/requests/message_search_request.go create mode 100644 web/pages/search-messages/index.vue create mode 100644 web/plugins/veutify.ts diff --git a/api/docs/docs.go b/api/docs/docs.go index 6665cb2c..fa55086e 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1313,6 +1313,89 @@ const docTemplate = `{ } } }, + "/messages/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "This returns the list of all messages based on the filter criteria including missed calls", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Search all messages of a user", + "parameters": [ + { + "type": "string", + "default": "+18005550199,+18005550100", + "description": "the owner's phone numbers", + "name": "owners", + "in": "query", + "required": true + }, + { + "minimum": 0, + "type": "integer", + "description": "number of messages to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter messages containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 200, + "minimum": 1, + "type": "integer", + "description": "number of messages to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/messages/send": { "post": { "security": [ @@ -1930,6 +2013,68 @@ const docTemplate = `{ } } }, + "/users/{userID}/api-keys": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Rotate the user's API key in case the current API Key is compromised", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Rotate the user's API Key", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the user to update", + "name": "userID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/users/{userID}/notifications": { "put": { "security": [ @@ -3133,7 +3278,7 @@ const docTemplate = `{ }, "missed_call_auto_reply": { "type": "string", - "example": "e.g This phone cannot receive calls. Please send an SMS instead." + "example": "e.g. This phone cannot receive calls. Please send an SMS instead." }, "phone_number": { "type": "string", @@ -3724,7 +3869,7 @@ var SwaggerInfo = &swag.Spec{ Version: "1.0", Host: "api.httpsms.com", BasePath: "/v1", - Schemes: []string{"http", "https"}, + Schemes: []string{"https"}, Title: "HTTP SMS API", Description: "API to send SMS messages using android [SmsManager](https://developer.android.com/reference/android/telephony/SmsManager) via HTTP", InfoInstanceName: "swagger", diff --git a/api/docs/swagger.json b/api/docs/swagger.json index a7fff945..16182844 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1,5 +1,5 @@ { - "schemes": ["http", "https"], + "schemes": ["https"], "swagger": "2.0", "info": { "description": "API to send SMS messages using android [SmsManager](https://developer.android.com/reference/android/telephony/SmsManager) via HTTP", @@ -1187,6 +1187,83 @@ } } }, + "/messages/search": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "This returns the list of all messages based on the filter criteria including missed calls", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Search all messages of a user", + "parameters": [ + { + "type": "string", + "default": "+18005550199,+18005550100", + "description": "the owner's phone numbers", + "name": "owners", + "in": "query", + "required": true + }, + { + "minimum": 0, + "type": "integer", + "description": "number of messages to skip", + "name": "skip", + "in": "query" + }, + { + "type": "string", + "description": "filter messages containing query", + "name": "query", + "in": "query" + }, + { + "maximum": 200, + "minimum": 1, + "type": "integer", + "description": "number of messages to return", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.MessagesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/messages/send": { "post": { "security": [ @@ -1748,6 +1825,62 @@ } } }, + "/users/{userID}/api-keys": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Rotate the user's API key in case the current API Key is compromised", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Rotate the user's API Key", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the user to update", + "name": "userID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/users/{userID}/notifications": { "put": { "security": [ @@ -2868,7 +3001,7 @@ }, "missed_call_auto_reply": { "type": "string", - "example": "e.g This phone cannot receive calls. Please send an SMS instead." + "example": "e.g. This phone cannot receive calls. Please send an SMS instead." }, "phone_number": { "type": "string", diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 564e8c22..86853745 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -682,7 +682,7 @@ definitions: example: 1 type: integer missed_call_auto_reply: - example: e.g This phone cannot receive calls. Please send an SMS instead. + example: e.g. This phone cannot receive calls. Please send an SMS instead. type: string phone_number: example: "+18005550199" @@ -2068,6 +2068,63 @@ paths: summary: Receive a new SMS message from a mobile phone tags: - Messages + /messages/search: + get: + consumes: + - application/json + description: + This returns the list of all messages based on the filter criteria + including missed calls + parameters: + - default: +18005550199,+18005550100 + description: the owner's phone numbers + in: query + name: owners + required: true + type: string + - description: number of messages to skip + in: query + minimum: 0 + name: skip + type: integer + - description: filter messages containing query + in: query + name: query + type: string + - description: number of messages to return + in: query + maximum: 200 + minimum: 1 + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: "#/definitions/responses.MessagesResponse" + "400": + description: Bad Request + schema: + $ref: "#/definitions/responses.BadRequest" + "401": + description: Unauthorized + schema: + $ref: "#/definitions/responses.Unauthorized" + "422": + description: Unprocessable Entity + schema: + $ref: "#/definitions/responses.UnprocessableEntity" + "500": + description: Internal Server Error + schema: + $ref: "#/definitions/responses.InternalServerError" + security: + - ApiKeyAuth: [] + summary: Search all messages of a user + tags: + - Messages /messages/send: post: consumes: @@ -2240,6 +2297,46 @@ paths: summary: Delete Phone tags: - Phones + /users/{userID}/api-keys: + delete: + consumes: + - application/json + description: Rotate the user's API key in case the current API Key is compromised + parameters: + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the user to update + in: path + name: userID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: "#/definitions/responses.UserResponse" + "400": + description: Bad Request + schema: + $ref: "#/definitions/responses.BadRequest" + "401": + description: Unauthorized + schema: + $ref: "#/definitions/responses.Unauthorized" + "422": + description: Unprocessable Entity + schema: + $ref: "#/definitions/responses.UnprocessableEntity" + "500": + description: Internal Server Error + schema: + $ref: "#/definitions/responses.InternalServerError" + security: + - ApiKeyAuth: [] + summary: Rotate the user's API Key + tags: + - Users /users/{userID}/notifications: put: consumes: @@ -2594,7 +2691,6 @@ paths: tags: - Webhooks schemes: - - http - https securityDefinitions: ApiKeyAuth: diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 6af05934..c724e6d4 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -21,7 +21,7 @@ import ( mexporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric" cloudtrace "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace" "github.com/NdoleStudio/httpsms/pkg/cache" - lemonsqueezy "github.com/NdoleStudio/lemonsqueezy-go" + "github.com/NdoleStudio/lemonsqueezy-go" "github.com/hashicorp/go-retryablehttp" "github.com/redis/go-redis/extra/redisotel/v9" "github.com/redis/go-redis/v9" diff --git a/api/pkg/events/message_api_deleted_event.go b/api/pkg/events/message_api_deleted_event.go index 49842830..e0de354f 100644 --- a/api/pkg/events/message_api_deleted_event.go +++ b/api/pkg/events/message_api_deleted_event.go @@ -13,13 +13,16 @@ const MessageAPIDeleted = "message.api.deleted" // MessageAPIDeletedPayload is the payload of the MessageAPIDeleted event type MessageAPIDeletedPayload struct { - MessageID uuid.UUID `json:"message_id"` - UserID entities.UserID `json:"user_id"` - Owner string `json:"owner"` - RequestID *string `json:"request_id"` - Contact string `json:"contact"` - Timestamp time.Time `json:"timestamp"` - Content string `json:"content"` - Encrypted bool `json:"encrypted"` - SIM entities.SIM `json:"sim"` + MessageID uuid.UUID `json:"message_id"` + UserID entities.UserID `json:"user_id"` + Owner string `json:"owner"` + RequestID *string `json:"request_id"` + Contact string `json:"contact"` + Timestamp time.Time `json:"timestamp"` + Content string `json:"content"` + Encrypted bool `json:"encrypted"` + PreviousMessageID *uuid.UUID `json:"previous_message_id"` + PreviousMessageStatus *entities.MessageStatus `json:"previous_message_status"` + PreviousMessageContent *string `json:"previous_message_content"` + SIM entities.SIM `json:"sim"` } diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go index d7502281..e7729499 100644 --- a/api/pkg/handlers/message_handler.go +++ b/api/pkg/handlers/message_handler.go @@ -55,6 +55,7 @@ func (h *MessageHandler) RegisterRoutes(router fiber.Router) { router.Post("/messages/calls/missed", h.PostCallMissed) router.Get("/messages/outstanding", h.GetOutstanding) router.Get("/messages", h.Index) + router.Get("/messages/search", h.Search) router.Post("/messages/:messageID/events", h.PostEvent) router.Delete("/messages/:messageID", h.Delete) } @@ -462,3 +463,49 @@ func (h *MessageHandler) PostCallMissed(c *fiber.Ctx) error { return h.responseOK(c, "missed call event stored successfully", message) } + +// Search returns a filtered list of messages of a user +// @Summary Search all messages of a user +// @Description This returns the list of all messages based on the filter criteria including missed calls +// @Security ApiKeyAuth +// @Tags Messages +// @Accept json +// @Produce json +// @Param owners query string true "the owner's phone numbers" default(+18005550199,+18005550100) +// @Param skip query int false "number of messages to skip" minimum(0) +// @Param query query string false "filter messages containing query" +// @Param limit query int false "number of messages to return" minimum(1) maximum(200) +// @Success 200 {object} responses.MessagesResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /messages/search [get] +func (h *MessageHandler) Search(c *fiber.Ctx) error { + ctx, span := h.tracer.StartFromFiberCtx(c) + defer span.End() + + ctxLogger := h.tracer.CtxLogger(h.logger, span) + + var request requests.MessageSearch + if err := c.QueryParser(&request); err != nil { + msg := fmt.Sprintf("cannot marshall params in [%s] into [%T]", c.OriginalURL(), request) + ctxLogger.Warn(stacktrace.Propagate(err, msg)) + return h.responseBadRequest(c, err) + } + + if errors := h.validator.ValidateMessageSearch(ctx, request.Sanitize()); len(errors) != 0 { + msg := fmt.Sprintf("validation errors [%s], while searching messages [%+#v]", spew.Sdump(errors), request) + ctxLogger.Warn(stacktrace.NewError(msg)) + return h.responseUnprocessableEntity(c, errors, "validation errors while searching messages") + } + + messages, err := h.service.SearchMessages(ctx, request.ToSearchParams(h.userIDFomContext(c))) + if err != nil { + msg := fmt.Sprintf("cannot search messages with params [%+#v]", request) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) + } + + return h.responseOK(c, fmt.Sprintf("found %d %s", len(messages), h.pluralize("message", len(messages))), messages) +} diff --git a/api/pkg/listeners/message_thread_listener.go b/api/pkg/listeners/message_thread_listener.go index 4bad67d1..2fb9f5a0 100644 --- a/api/pkg/listeners/message_thread_listener.go +++ b/api/pkg/listeners/message_thread_listener.go @@ -79,13 +79,13 @@ func (listener *MessageThreadListener) onMessageDeleted(ctx context.Context, eve ctx, span := listener.tracer.Start(ctx) defer span.End() - var payload events.MessageAPIDeletedPayload - if err := event.DataAs(&payload); err != nil { + payload := new(events.MessageAPIDeletedPayload) + if err := event.DataAs(payload); err != nil { msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - if err := listener.service.UpdateAfterDeletedMessage(ctx, payload.UserID, payload.MessageID); err != nil { + if err := listener.service.UpdateAfterDeletedMessage(ctx, payload); err != nil { msg := fmt.Sprintf("cannot update thread for message with ID [%s] for event with ID [%s]", payload.MessageID, event.ID()) return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } diff --git a/api/pkg/repositories/gorm_message_repository.go b/api/pkg/repositories/gorm_message_repository.go index 60a0284e..0fc4e7e0 100644 --- a/api/pkg/repositories/gorm_message_repository.go +++ b/api/pkg/repositories/gorm_message_repository.go @@ -92,6 +92,78 @@ func (repository *gormMessageRepository) Index(ctx context.Context, userID entit return messages, nil } +func (repository *gormMessageRepository) LastMessage(ctx context.Context, userID entities.UserID, owner string, contact string) (*entities.Message, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + query := repository.db. + WithContext(ctx). + Where("user_id = ?", userID). + Where("owner = ?", owner). + Where("contact = ?", contact) + + message := new(entities.Message) + + err := query.Order("order_timestamp DESC").First(&message).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + msg := fmt.Sprintf("cannot get last message for [%s] with owner [%s] and contact [%s]", userID, owner, contact) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) + } + + if err != nil { + msg := fmt.Sprintf("cannot get last message for [%s] with owner [%s] and contact [%s]", userID, owner, contact) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return message, nil +} + +func (repository *gormMessageRepository) Search(ctx context.Context, userID entities.UserID, owners []string, types []entities.MessageType, statuses []entities.MessageStatus, params IndexParams) ([]*entities.Message, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + query := repository.db. + WithContext(ctx). + Where("user_id = ?", userID) + + if len(owners) > 0 { + query = query.Where("owner IN ?", owners) + } + if len(types) > 0 { + query = query.Where("type IN ?", types) + } + if len(statuses) > 0 { + query = query.Where("status IN ?", statuses) + } + + if len(params.Query) > 0 { + queryPattern := "%" + params.Query + "%" + subQuery := repository.db.Where("content ILIKE ?", queryPattern). + Or("contact ILIKE ?", queryPattern). + Or("failure_reason ILIKE ?", queryPattern). + Or("request_id ILIKE ?", queryPattern) + + if _, err := uuid.Parse(params.Query); err == nil { + subQuery = subQuery.Or("id = ?", params.Query) + } + + query = query.Where(subQuery) + } + + messages := make([]*entities.Message, 0, params.Limit) + err := query.Order(repository.order(params, "created_at")). + Limit(params.Limit). + Offset(params.Skip). + Find(&messages). + Error + if err != nil { + msg := fmt.Sprintf("cannot search messages with for user [%s] params [%+#v]", userID, params) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return messages, nil +} + // Store a new entities.Message func (repository *gormMessageRepository) Store(ctx context.Context, message *entities.Message) error { ctx, span := repository.tracer.Start(ctx) @@ -171,3 +243,17 @@ func (repository *gormMessageRepository) GetOutstanding(ctx context.Context, use return message, nil } + +func (repository *gormMessageRepository) order(params IndexParams, defaultSortBy string) string { + sortBy := defaultSortBy + if len(params.SortBy) > 0 { + sortBy = params.SortBy + } + + direction := "ASC" + if params.SortDescending { + direction = "DESC" + } + + return fmt.Sprintf("%s %s", sortBy, direction) +} diff --git a/api/pkg/repositories/message_repository.go b/api/pkg/repositories/message_repository.go index fced76ca..971aecde 100644 --- a/api/pkg/repositories/message_repository.go +++ b/api/pkg/repositories/message_repository.go @@ -21,6 +21,12 @@ type MessageRepository interface { // Index entities.Message between 2 phone numbers Index(ctx context.Context, userID entities.UserID, owner string, contact string, params IndexParams) (*[]entities.Message, error) + // LastMessage fetches the last message between an owner and a contact + LastMessage(ctx context.Context, userID entities.UserID, owner string, contact string) (*entities.Message, error) + + // Search entities.Message for a user + Search(ctx context.Context, userID entities.UserID, owners []string, types []entities.MessageType, statuses []entities.MessageStatus, params IndexParams) ([]*entities.Message, error) + // GetOutstanding fetches an entities.Message which is outstanding GetOutstanding(ctx context.Context, userID entities.UserID, messageID uuid.UUID) (*entities.Message, error) diff --git a/api/pkg/repositories/repository.go b/api/pkg/repositories/repository.go index 88dd8660..32ba4337 100644 --- a/api/pkg/repositories/repository.go +++ b/api/pkg/repositories/repository.go @@ -8,9 +8,11 @@ import ( // IndexParams parameters for indexing a database table type IndexParams struct { - Skip int `json:"skip"` - Query string `json:"query"` - Limit int `json:"take"` + Skip int `json:"skip"` + SortBy string `json:"sort"` + SortDescending bool `json:"sort_descending"` + Query string `json:"query"` + Limit int `json:"take"` } const ( diff --git a/api/pkg/requests/message_search_request.go b/api/pkg/requests/message_search_request.go new file mode 100644 index 00000000..358f9350 --- /dev/null +++ b/api/pkg/requests/message_search_request.go @@ -0,0 +1,67 @@ +package requests + +import ( + "strings" + + "github.com/NdoleStudio/httpsms/pkg/entities" + + "github.com/NdoleStudio/httpsms/pkg/repositories" + + "github.com/NdoleStudio/httpsms/pkg/services" +) + +// MessageSearch is the payload fetching entities.Message +type MessageSearch struct { + request + Skip string `json:"skip" query:"skip"` + Owners []string `json:"owners" query:"owners"` + Types []string `json:"types" query:"types"` + Statuses []string `json:"statuses" query:"statuses"` + Query string `json:"query" query:"query"` + SortBy string `json:"sort_by" query:"sort_by"` + SortDescending bool `json:"sort_descending" query:"sort_descending"` + Limit string `json:"limit" query:"limit"` +} + +// Sanitize sets defaults to MessageSearch +func (input *MessageSearch) Sanitize() MessageSearch { + if strings.TrimSpace(input.Limit) == "" { + input.Limit = "100" + } + + input.Query = strings.TrimSpace(input.Query) + + input.Skip = strings.TrimSpace(input.Skip) + if input.Skip == "" { + input.Skip = "0" + } + + return *input +} + +// ToSearchParams converts request to services.MessageSearchParams +func (input *MessageSearch) ToSearchParams(userID entities.UserID) *services.MessageSearchParams { + var types []entities.MessageType + for _, t := range input.Types { + types = append(types, entities.MessageType(t)) + } + + var statuses []entities.MessageStatus + for _, s := range input.Statuses { + statuses = append(statuses, entities.MessageStatus(s)) + } + + return &services.MessageSearchParams{ + IndexParams: repositories.IndexParams{ + Skip: input.getInt(input.Skip), + Query: input.Query, + SortBy: input.SortBy, + SortDescending: input.SortDescending, + Limit: input.getInt(input.Limit), + }, + UserID: userID, + Owners: input.Owners, + Types: types, + Statuses: statuses, + } +} diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index d28f3124..155a4d0a 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -106,16 +106,29 @@ func (service *MessageService) DeleteMessage(ctx context.Context, source string, return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) } + var prevID *uuid.UUID + var prevStatus *entities.MessageStatus + var prevContent *string + previousMessage, err := service.repository.LastMessage(ctx, message.UserID, message.Owner, message.Contact) + if err == nil { + prevID = &previousMessage.ID + prevStatus = &previousMessage.Status + prevContent = &previousMessage.Content + } + event, err := service.createEvent(events.MessageAPIDeleted, source, &events.MessageAPIDeletedPayload{ - MessageID: message.ID, - UserID: message.UserID, - Owner: message.Owner, - Encrypted: message.Encrypted, - RequestID: message.RequestID, - Contact: message.Contact, - Timestamp: time.Now().UTC(), - Content: message.Content, - SIM: message.SIM, + MessageID: message.ID, + UserID: message.UserID, + Owner: message.Owner, + Encrypted: message.Encrypted, + RequestID: message.RequestID, + Contact: message.Contact, + Timestamp: time.Now().UTC(), + Content: message.Content, + PreviousMessageID: prevID, + PreviousMessageStatus: prevStatus, + PreviousMessageContent: prevContent, + SIM: message.SIM, }) if err != nil { msg := fmt.Sprintf("cannot create [%T] for message with ID [%s]", event, message.ID) @@ -880,6 +893,32 @@ func (service *MessageService) CheckExpired(ctx context.Context, params MessageC return nil } +// MessageSearchParams are parameters for searching messages +type MessageSearchParams struct { + repositories.IndexParams + UserID entities.UserID + Owners []string + Types []entities.MessageType + Statuses []entities.MessageStatus +} + +// SearchMessages fetches all the messages for a user +func (service *MessageService) SearchMessages(ctx context.Context, params *MessageSearchParams) ([]*entities.Message, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + ctxLogger := service.tracer.CtxLogger(service.logger, span) + + messages, err := service.repository.Search(ctx, params.UserID, params.Owners, params.Types, params.Statuses, params.IndexParams) + if err != nil { + msg := fmt.Sprintf("could not search messages with parms [%+#v]", params) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + ctxLogger.Info(fmt.Sprintf("fetched [%d] messages with prams [%+#v]", len(messages), params)) + return messages, nil +} + func (service *MessageService) phoneSettings(ctx context.Context, userID entities.UserID, owner string) (uint, entities.SIM) { ctx, span := service.tracer.Start(ctx) defer span.End() diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index cdf1eba6..18553c01 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -117,16 +117,44 @@ func (service *MessageThreadService) UpdateStatus(ctx context.Context, params Me } // UpdateAfterDeletedMessage updates a thread after the last message has been deleted -func (service *MessageThreadService) UpdateAfterDeletedMessage(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error { +func (service *MessageThreadService) UpdateAfterDeletedMessage(ctx context.Context, payload *events.MessageAPIDeletedPayload) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() - if err := service.repository.UpdateAfterDeletedMessage(ctx, userID, messageID); err != nil { - msg := fmt.Sprintf("cannot delete last message from thread with messageID [%s] and userID [%s]", messageID, userID) + thread, err := service.repository.LoadByOwnerContact(ctx, payload.UserID, payload.Owner, payload.Contact) + if err != nil { + msg := fmt.Sprintf("cannot find thread for user [%s] with owner [%s], and contact [%s]", payload.UserID, payload.Owner, payload.Contact) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if payload.PreviousMessageID == nil { + if err = service.repository.Delete(ctx, thread.UserID, thread.ID); err != nil { + msg := fmt.Sprintf("cannot delete thread with ID [%s] for user [%s] and owner [%s]", thread.ID, thread.UserID, thread.Owner) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return nil + } + msg := fmt.Sprintf("previous message ID is nil for thread with ID [%s] and user [%s]", thread.ID, thread.UserID) + ctxLogger.Info(msg) + return nil + } + + if thread.LastMessageID != nil && *thread.LastMessageID != payload.MessageID { + msg := fmt.Sprintf("last message ID [%s] does not match message ID [%s] for thread with ID [%s]", *thread.LastMessageID, payload.MessageID, thread.ID) + ctxLogger.Info(msg) + return nil + } + + thread.LastMessageContent = payload.PreviousMessageContent + thread.LastMessageID = payload.PreviousMessageID + thread.Status = *payload.PreviousMessageStatus + thread.UpdatedAt = time.Now().UTC() + + if err = service.repository.Update(ctx, thread); err != nil { + msg := fmt.Sprintf("cannot update thread with ID [%s] for user with ID [%s]", thread.ID, thread.UserID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - ctxLogger.Info(fmt.Sprintf("last message has been removed from thread with messageID [%s] and userID [%s]", messageID, userID)) + ctxLogger.Info(fmt.Sprintf("last message has been removed from thread with ID [%s] and userID [%s]", thread.ID, thread.UserID)) return nil } diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index 799a4d33..3d8b5a1d 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -207,6 +207,59 @@ func (validator MessageHandlerValidator) ValidateMessageIndex(_ context.Context, return v.ValidateStruct() } +// ValidateMessageSearch validates the requests.MessageSearch request +func (validator MessageHandlerValidator) ValidateMessageSearch(_ context.Context, request requests.MessageSearch) url.Values { + v := govalidator.New(govalidator.Options{ + Data: &request, + Rules: govalidator.MapData{ + "owners": []string{ + multipleContactPhoneNumberRule, + }, + "types": []string{ + multipleInRule + ":" + strings.Join([]string{ + entities.MessageTypeCallMissed, + entities.MessageTypeMobileOriginated, + entities.MessageTypeMobileTerminated, + }, ","), + }, + "statuses": []string{ + multipleInRule + ":" + strings.Join([]string{ + entities.MessageStatusPending, + entities.MessageStatusSent, + entities.MessageStatusDelivered, + entities.MessageStatusFailed, + entities.MessageStatusExpired, + entities.MessageStatusReceived, + }, ","), + }, + "sort_by": []string{ + "in:" + strings.Join([]string{ + "created_at", + "owner", + "contact", + "type", + "status", + }, ","), + }, + "limit": []string{ + "required", + "numeric", + "min:1", + "max:200", + }, + "skip": []string{ + "required", + "numeric", + "min:0", + }, + "query": []string{ + "max:100", + }, + }, + }) + return v.ValidateStruct() +} + // ValidateMessageEvent validates the requests.MessageEvent request func (validator MessageHandlerValidator) ValidateMessageEvent(_ context.Context, request requests.MessageEvent) url.Values { v := govalidator.New(govalidator.Options{ diff --git a/api/pkg/validators/validator.go b/api/pkg/validators/validator.go index d6f8cb01..12a1a015 100644 --- a/api/pkg/validators/validator.go +++ b/api/pkg/validators/validator.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "regexp" + "strings" "github.com/NdoleStudio/httpsms/pkg/events" @@ -18,6 +19,7 @@ const ( phoneNumberRule = "phoneNumber" contactPhoneNumberRule = "contactPhoneNumber" multipleContactPhoneNumberRule = "multipleContactPhoneNumber" + multipleInRule = "multipleIn" webhookEventsRule = "webhookEvents" ) @@ -68,6 +70,31 @@ func init() { return nil }) + govalidator.AddCustomRule(multipleInRule, func(field string, rule string, message string, value interface{}) error { + values, ok := value.([]string) + if !ok { + return fmt.Errorf("the %s field must be a string array", field) + } + + allowlist := strings.Split(strings.TrimPrefix(rule, multipleInRule+":"), ",") + contains := func(str string) bool { + for _, a := range allowlist { + if a == str { + return true + } + } + return false + } + + for index, item := range values { + if !contains(item) { + return fmt.Errorf("the %s field in contains an invalid value [%s] at index [%d] ", field, item, index) + } + } + + return nil + }) + govalidator.AddCustomRule(webhookEventsRule, func(field string, rule string, message string, value interface{}) error { input, ok := value.([]string) if !ok { diff --git a/web/components/MessageThreadHeader.vue b/web/components/MessageThreadHeader.vue index 2086354d..f41e7509 100644 --- a/web/components/MessageThreadHeader.vue +++ b/web/components/MessageThreadHeader.vue @@ -135,6 +135,22 @@ + + + {{ mdiMagnify }} + + + + + Search Messages + + + + {{ mdiAccountCog }} @@ -211,6 +227,7 @@ import { mdiPackageUp, mdiPackageDown, mdiDotsVertical, + mdiMagnify, mdiCommentTextMultipleOutline, mdiCircle, } from '@mdi/js' @@ -231,6 +248,7 @@ export default class MessageThreadHeader extends Vue { mdiDotsVertical = mdiDotsVertical mdiCircle = mdiCircle mdiBatteryCharging = mdiBatteryChargingHigh + mdiMagnify = mdiMagnify get owners(): Array { return this.$store.getters.getPhones.map( diff --git a/web/models/message.ts b/web/models/message.ts index b8948f93..fa06e4b4 100644 --- a/web/models/message.ts +++ b/web/models/message.ts @@ -15,3 +15,14 @@ export interface Message { type: string updated_at: string } + +export interface SearchMessagesRequest { + owners: string[] + types: string[] + statuses: string[] + query: string + sort_by: string + sort_descending: boolean + skip: number + limit: number +} diff --git a/web/pages/search-messages/index.vue b/web/pages/search-messages/index.vue new file mode 100644 index 00000000..4e50e01a --- /dev/null +++ b/web/pages/search-messages/index.vue @@ -0,0 +1,482 @@ + + + diff --git a/web/plugins/filters.ts b/web/plugins/filters.ts index 5c838046..2a7c1a99 100644 --- a/web/plugins/filters.ts +++ b/web/plugins/filters.ts @@ -2,7 +2,7 @@ import Vue from 'vue' import { intervalToDuration, formatDuration } from 'date-fns' import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js' -Vue.filter('phoneNumber', (value: string): string => { +export const formatPhoneNumber = (value: string) => { if (!isValidPhoneNumber(value)) { return value } @@ -11,6 +11,10 @@ Vue.filter('phoneNumber', (value: string): string => { return phoneNumber.formatInternational() } return value +} + +Vue.filter('phoneNumber', (value: string): string => { + return formatPhoneNumber(value) }) Vue.filter('phoneCountry', (value: string): string => { @@ -56,3 +60,7 @@ Vue.filter('humanizeTime', (value: string): string => { }) return formatDuration(durations) }) + +Vue.filter('capitalize', (value: string): string => { + return value.charAt(0).toUpperCase() + value.slice(1) +}) diff --git a/web/plugins/veutify.ts b/web/plugins/veutify.ts new file mode 100644 index 00000000..be8b07eb --- /dev/null +++ b/web/plugins/veutify.ts @@ -0,0 +1,67 @@ +import Vue from 'vue' +import { Route } from 'vue-router' +import { DataOptions } from 'vuetify' + +export type VForm = Vue & { + validate: () => boolean + resetValidation: () => boolean + reset: () => void +} + +export type FormInputType = string | null | File + +export type FormValidationRule = (value: FormInputType) => string | boolean + +export type FormValidationRules = Array + +export interface SelectItem { + text: string + value: string | number | boolean +} + +export interface DatatableFooterProps { + itemsPerPage: number + itemsPerPageOptions: Array +} + +export const DefaultFooterProps: DatatableFooterProps = { + itemsPerPage: 100, + itemsPerPageOptions: [10, 50, 100, 200], +} + +export type ParseParamsResponse = { + options: DataOptions + query: string | null +} + +export const parseFilterOptionsFromParams = ( + route: Route, + options: DataOptions, +): ParseParamsResponse => { + let query = null + Object.keys(route.query).forEach((value: string) => { + if (value === 'itemsPerPage') { + options.itemsPerPage = parseInt( + (route.query[value] as string) ?? options.itemsPerPage.toString(), + ) + } + + if (value === 'sortBy') { + options.sortBy = [(route.query[value] as string) ?? options.sortBy[0]] + } + + if (value === 'sortDesc') { + options.sortDesc = [!(route.query[value] === 'false')] + } + + if (value === 'page') { + options.page = parseInt( + (route.query[value] as string) ?? options.page.toString(), + ) + } + if (value === 'query') { + query = route.query[value] + } + }) + return { options, query } +} diff --git a/web/store/index.ts b/web/store/index.ts index d89ebf4f..53a7cd97 100644 --- a/web/store/index.ts +++ b/web/store/index.ts @@ -1,13 +1,14 @@ import { ActionContext } from 'vuex' import { AxiosError, AxiosResponse } from 'axios' import { MessageThread } from '~/models/message-thread' -import { Message } from '~/models/message' +import { Message, SearchMessagesRequest } from '~/models/message' import { Heartbeat } from '~/models/heartbeat' import axios, { setApiKey, setAuthHeader } from '~/plugins/axios' import { User } from '~/models/user' import { BillingUsage } from '~/models/billing' import { EntitiesDiscord, + EntitiesMessage, EntitiesPhone, EntitiesUser, EntitiesWebhook, @@ -18,6 +19,7 @@ import { RequestsWebhookUpdate, ResponsesDiscordResponse, ResponsesDiscordsResponse, + ResponsesMessagesResponse, ResponsesNoContent, ResponsesOkString, ResponsesUnprocessableEntity, @@ -511,6 +513,24 @@ export const actions = { }) }, + searchMessages( + _: ActionContext, + payload: SearchMessagesRequest, + ) { + return new Promise((resolve, reject) => { + axios + .get(`/v1/messages/search`, { + params: payload, + }) + .then((response: AxiosResponse) => { + resolve(response.data.data) + }) + .catch(async (error: AxiosError) => { + reject(error) + }) + }) + }, + setThreadId(context: ActionContext, threadId: string | null) { context.commit('setThreadId', threadId) }, From c2e014703aa52ca15a672fe0905ec9f39a3581e6 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 9 Jun 2024 22:19:30 +0300 Subject: [PATCH 003/583] Fix javascript error --- web/store/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/store/index.ts b/web/store/index.ts index 53a7cd97..d64959bc 100644 --- a/web/store/index.ts +++ b/web/store/index.ts @@ -525,7 +525,7 @@ export const actions = { .then((response: AxiosResponse) => { resolve(response.data.data) }) - .catch(async (error: AxiosError) => { + .catch((error: AxiosError) => { reject(error) }) }) From 52922720e18cdb01fc216c2f831059aeb986f37b Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Tue, 11 Jun 2024 10:21:53 +0300 Subject: [PATCH 004/583] fix the response code range check --- android/app/src/main/java/com/httpsms/HttpSmsApiService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt index f7281b93..3a48a40b 100644 --- a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt +++ b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt @@ -91,7 +91,7 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { if (!response.isSuccessful) { Timber.e("error response [${response.body?.string()}] with code [${response.code}] while receiving message [${body}]") response.close() - return response.code == 422 || response.code == 401 + return response.code in 400..499 } val message = ResponseMessage.fromJson(response.body!!.string()) @@ -121,7 +121,7 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { if (!response.isSuccessful) { Timber.e("error response [${response.body?.string()}] with code [${response.code}] while sending missed call event [${body}]") response.close() - return response.code == 422 + return response.code in 400..499 } response.close() From 299fa2398802fa28762fc3f4dcb69de04ee850b6 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Tue, 11 Jun 2024 10:27:36 +0300 Subject: [PATCH 005/583] Fix the inbound/outbound labels --- web/pages/search-messages/index.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/pages/search-messages/index.vue b/web/pages/search-messages/index.vue index 4e50e01a..0bbd316d 100644 --- a/web/pages/search-messages/index.vue +++ b/web/pages/search-messages/index.vue @@ -191,11 +191,11 @@ {{ mdiCallReceived }} - inbound + outbound {{ mdiCallMade }} - outbound + inbound