diff --git a/android/app/build.gradle b/android/app/build.gradle index f49acb2b..66eb98c1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -67,6 +67,7 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'com.googlecode.libphonenumber:libphonenumber:9.0.4' + implementation 'com.klinkerapps:android-smsmms:5.2.6' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6d704ade..409b7140 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -90,6 +90,17 @@ + + + + + diff --git a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt index 8f1e448c..03c99bb4 100644 --- a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt +++ b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt @@ -9,6 +9,13 @@ import com.google.firebase.messaging.RemoteMessage import com.httpsms.SentReceiver.FailedMessageWorker import timber.log.Timber +import com.google.android.mms.pdu_alt.CharacterSets +import com.google.android.mms.pdu_alt.EncodedStringValue +import com.google.android.mms.pdu_alt.PduBody +import com.google.android.mms.pdu_alt.PduComposer +import com.google.android.mms.pdu_alt.PduPart +import com.google.android.mms.pdu_alt.SendReq + class MyFirebaseMessagingService : FirebaseMessagingService() { // [START receive_message] override fun onMessageReceived(remoteMessage: RemoteMessage) { @@ -158,6 +165,11 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { } Receiver.register(applicationContext) + + if (message.attachments != null && message.attachments.isNotEmpty()) { + return handleMmsMessage(message) + } + val parts = getMessageParts(applicationContext, message) if (parts.size == 1) { return handleSingleMessage(message, parts.first()) @@ -165,6 +177,107 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { return handleMultipartMessage(message, parts) } + private fun handleMmsMessage(message: Message): Result { + Timber.d("Processing MMS for message ID [${message.id}]") + val apiService = HttpSmsApiService.create(applicationContext) + + val downloadedFiles = mutableListOf() + + try { + for ((index, attachment) in message.attachments!!.withIndex()) { + val file = apiService.downloadAttachment(applicationContext, attachment.url, message.id, index) + if (file == null) { + handleFailed(applicationContext, message.id, "Failed to download attachment or file size exceeded 1.5MB.") + return Result.failure() + } + downloadedFiles.add(file) + } + + val sendReq = SendReq() + + val encodedContact = EncodedStringValue(message.contact) + sendReq.to = arrayOf(encodedContact) + + val pduBody = PduBody() + + if (message.content.isNotEmpty()) { + val textPart = PduPart() + textPart.setCharset(CharacterSets.UTF_8) + textPart.contentType = "text/plain".toByteArray() + textPart.name = "text".toByteArray() + textPart.contentId = "text".toByteArray() + textPart.contentLocation = "text".toByteArray() + + var messageBody = message.content + val encryptionKey = Settings.getEncryptionKey(applicationContext) + if (message.encrypted && !encryptionKey.isNullOrEmpty()) { + messageBody = Encrypter.decrypt(encryptionKey, messageBody) + } + textPart.data = messageBody.toByteArray(Charsets.UTF_8) + + pduBody.addPart(textPart) + } + + for ((index, file) in downloadedFiles.withIndex()) { + val attachment = message.attachments[index] + val fileBytes = file.readBytes() + + val mediaPart = PduPart() + mediaPart.contentType = attachment.contentType.toByteArray() + + val fileName = "attachment_$index".toByteArray() + mediaPart.name = fileName + mediaPart.contentId = fileName + mediaPart.contentLocation = fileName + mediaPart.data = fileBytes + + pduBody.addPart(mediaPart) + } + + sendReq.body = pduBody + + val pduComposer = PduComposer(applicationContext, sendReq) + val pduBytes = pduComposer.make() + + if (pduBytes == null) { + Timber.e("PduComposer failed to generate PDU byte array") + handleFailed(applicationContext, message.id, "Failed to compose MMS PDU.") + return Result.failure() + } + + val mmsDir = java.io.File(applicationContext.cacheDir, "mms_attachments") + if (!mmsDir.exists()) { + mmsDir.mkdirs() + } + + val pduFile = java.io.File(mmsDir, "pdu_${message.id}.dat") + java.io.FileOutputStream(pduFile).use { it.write(pduBytes) } + + val pduUri = androidx.core.content.FileProvider.getUriForFile( + applicationContext, + "${BuildConfig.APPLICATION_ID}.fileprovider", + pduFile + ) + + val sentIntent = createPendingIntent(message.id, SmsManagerService.sentAction()) + SmsManagerService().sendMultimediaMessage(applicationContext, pduUri, message.sim, sentIntent) + + Timber.d("Successfully dispatched MMS for message ID [${message.id}]") + return Result.success() + + } catch (e: Exception) { + Timber.e(e, "Failed to send MMS for message ID [${message.id}]") + handleFailed(applicationContext, message.id, e.message ?: "Internal error while building or sending MMS.") + return Result.failure() + } finally { + downloadedFiles.forEach { file -> + if (file.exists()) { + file.delete() + } + } + } + } + private fun handleMultipartMessage(message:Message, parts: ArrayList): Result { Timber.d("sending multipart SMS for message with ID [${message.id}]") return try { diff --git a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt index 3d813e13..9581cedc 100644 --- a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt +++ b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt @@ -11,6 +11,11 @@ import java.net.URI import java.net.URL import java.util.logging.Level import java.util.logging.Logger.getLogger +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { @@ -156,6 +161,69 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { return true } + fun InputStream.copyToWithLimit( + out: OutputStream, + limit: Long, + bufferSize: Int = DEFAULT_BUFFER_SIZE + ): Long { + var bytesCopied: Long = 0 + val buffer = ByteArray(bufferSize) + var bytes = read(buffer) + + while (bytes >= 0) { + bytesCopied += bytes + + if (bytesCopied > limit) { + throw IOException("Download aborted: File exceeded maximum allowed size of $limit bytes.") + } + + out.write(buffer, 0, bytes) + bytes = read(buffer) + } + return bytesCopied + } + + // Downloads the attachment URL content locally + fun downloadAttachment(context: Context, urlString: String, messageId: String, attachmentIndex: Int): File? { + val request = Request.Builder().url(urlString).build() + + try { + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + Timber.e("Failed to download attachment: ${response.code}") + response.close() + return null + } + + val maxSizeBytes = 1.5 * 1024 * 1024 // most (modern?) carriers have a 2MB limit, so targetting 1.5MB should be safe + val contentLength = response.body?.contentLength() ?: -1L + if (contentLength > maxSizeBytes) { + Timber.e("Attachment is too large ($contentLength bytes).") + response.close() + return null + } + + val mmsDir = File(context.cacheDir, "mms_attachments") + if (!mmsDir.exists()) { + mmsDir.mkdirs() + } + + val tempFile = File(mmsDir, "mms_${messageId}_$attachmentIndex") + val inputStream = response.body?.byteStream() + val outputStream = FileOutputStream(tempFile) + + inputStream?.copyToWithLimit(outputStream, maxSizeBytes.toLong()) + + outputStream.close() + inputStream?.close() + response.close() + + return tempFile + } catch (e: Exception) { + Timber.e(e, "Exception while downloading attachment") + return null + } + } private fun sendEvent(messageId: String, event: String, timestamp: String, reason: String? = null): Boolean { var reasonString = "null" diff --git a/android/app/src/main/java/com/httpsms/Models.kt b/android/app/src/main/java/com/httpsms/Models.kt index ccfe590b..1ee2dbc8 100644 --- a/android/app/src/main/java/com/httpsms/Models.kt +++ b/android/app/src/main/java/com/httpsms/Models.kt @@ -29,6 +29,14 @@ data class Phone ( val userID: String, ) +// mms attachment +data class Attachment ( + @Json(name = "content_type") + val contentType: String, + + val url: String +) + data class Message ( val contact: String, val content: String, @@ -68,5 +76,7 @@ data class Message ( val type: String, @Json(name = "updated_at") - val updatedAt: String + val updatedAt: String, + + val attachments: List? = null ) diff --git a/android/app/src/main/java/com/httpsms/SentReceiver.kt b/android/app/src/main/java/com/httpsms/SentReceiver.kt index 7995c35c..8786ba2c 100644 --- a/android/app/src/main/java/com/httpsms/SentReceiver.kt +++ b/android/app/src/main/java/com/httpsms/SentReceiver.kt @@ -14,9 +14,12 @@ import androidx.work.Worker import androidx.work.WorkerParameters import androidx.work.workDataOf import timber.log.Timber +import java.io.File internal class SentReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { + val messageId = intent.getStringExtra(Constants.KEY_MESSAGE_ID) + cleanupPduFile(context, messageId) when (resultCode) { Activity.RESULT_OK -> handleMessageSent(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID)) SmsManager.RESULT_ERROR_GENERIC_FAILURE -> handleMessageFailed(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID), "GENERIC_FAILURE") @@ -27,6 +30,26 @@ internal class SentReceiver : BroadcastReceiver() { } } + private fun cleanupPduFile(context: Context, messageId: String?) { + if (messageId == null) return + + try { + val baseMessageId = messageId.substringBefore(".") + val mmsDir = File(context.cacheDir, "mms_attachments") + val pduFile = File(mmsDir, "pdu_$baseMessageId.dat") + + if (pduFile.exists()) { + if (pduFile.delete()) { + Timber.d("Cleaned up PDU file for message ID [$baseMessageId]") + } else { + Timber.w("Failed to delete PDU file for message ID [$baseMessageId]") + } + } + } catch (e: Exception) { + Timber.e(e, "Error cleaning up PDU file for message ID [$messageId]") + } + } + private fun handleMessageSent(context: Context, messageId: String?) { if (!Receiver.isValid(context, messageId)) { return diff --git a/android/app/src/main/java/com/httpsms/SmsManagerService.kt b/android/app/src/main/java/com/httpsms/SmsManagerService.kt index 17987b5c..5f7ce6f5 100644 --- a/android/app/src/main/java/com/httpsms/SmsManagerService.kt +++ b/android/app/src/main/java/com/httpsms/SmsManagerService.kt @@ -76,4 +76,10 @@ class SmsManagerService { context.getSystemService(SmsManager::class.java).createForSubscriptionId(subscriptionId) } } + + // Wrapper for the smsManager's sendMultimediaMessage + fun sendMultimediaMessage(context: Context, pduUri: android.net.Uri, sim: String, sentIntent: PendingIntent) { + val smsManager = getSmsManager(context, sim) + smsManager.sendMultimediaMessage(context, pduUri, null, null, sentIntent) + } } diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..0df3af41 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 632e4706..02d37738 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -534,6 +534,7 @@ func (container *Container) MessageHandlerValidator() (validator *validators.Mes container.Tracer(), container.PhoneService(), container.TurnstileTokenValidator(), + container.Cache(), ) } @@ -556,6 +557,7 @@ func (container *Container) BulkMessageHandlerValidator() (validator *validators container.Tracer(), container.PhoneService(), container.UserService(), + container.Cache(), ) } diff --git a/api/pkg/entities/message.go b/api/pkg/entities/message.go index bf846c0b..6f0f75e6 100644 --- a/api/pkg/entities/message.go +++ b/api/pkg/entities/message.go @@ -1,6 +1,7 @@ package entities import ( + "strings" "time" "github.com/google/uuid" @@ -81,17 +82,23 @@ func (s SIM) String() string { return string(s) } +type MessageAttachment struct { + ContentType string `json:"content_type" example:"image/jpeg"` + URL string `json:"url" example:"https://example.com/image.jpg"` +} + // Message represents a message sent between 2 phone numbers type Message struct { - ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` - RequestID *string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"` - Owner string `json:"owner" example:"+18005550199"` - UserID UserID `json:"user_id" gorm:"index:idx_messages__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` - Contact string `json:"contact" example:"+18005550100"` - Content string `json:"content" example:"This is a sample text message"` - Encrypted bool `json:"encrypted" example:"false" gorm:"default:false"` - Type MessageType `json:"type" example:"mobile-terminated"` - Status MessageStatus `json:"status" example:"pending"` + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` + RequestID *string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"` + Owner string `json:"owner" example:"+18005550199"` + UserID UserID `json:"user_id" gorm:"index:idx_messages__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` + Contact string `json:"contact" example:"+18005550100"` + Content string `json:"content" example:"This is a sample text message"` + Attachments []MessageAttachment `json:"attachments,omitempty" gorm:"type:json;serializer:json"` + Encrypted bool `json:"encrypted" example:"false" gorm:"default:false"` + Type MessageType `json:"type" example:"mobile-terminated"` + Status MessageStatus `json:"status" example:"pending"` // SIM is the SIM card to use to send the message // * SMS1: use the SIM card in slot 1 // * SMS2: use the SIM card in slot 2 @@ -227,3 +234,19 @@ func (message *Message) updateOrderTimestamp(timestamp time.Time) { message.OrderTimestamp = timestamp } } + +func GetAttachmentContentType(url string) string { + // Since there's no easy way to set a type in the CSV, defaulting to octet-stream and then just checking the file extension in the URL + contentType := "application/octet-stream" + lowerURL := strings.ToLower(url) + if strings.HasSuffix(lowerURL, ".jpg") || strings.HasSuffix(lowerURL, ".jpeg") { + contentType = "image/jpeg" + } else if strings.HasSuffix(lowerURL, ".png") { + contentType = "image/png" + } else if strings.HasSuffix(lowerURL, ".gif") { + contentType = "image/gif" + } else if strings.HasSuffix(lowerURL, ".mp4") { + contentType = "video/mp4" + } + return contentType +} diff --git a/api/pkg/events/message_api_sent_event.go b/api/pkg/events/message_api_sent_event.go index 7abea843..9aeabd90 100644 --- a/api/pkg/events/message_api_sent_event.go +++ b/api/pkg/events/message_api_sent_event.go @@ -13,15 +13,16 @@ const EventTypeMessageAPISent = "message.api.sent" // MessageAPISentPayload is the payload of the EventTypeMessageSent event type MessageAPISentPayload struct { - MessageID uuid.UUID `json:"message_id"` - UserID entities.UserID `json:"user_id"` - Owner string `json:"owner"` - RequestID *string `json:"request_id"` - MaxSendAttempts uint `json:"max_send_attempts"` - Contact string `json:"contact"` - ScheduledSendTime *time.Time `json:"scheduled_send_time"` - RequestReceivedAt time.Time `json:"request_received_at"` - 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"` + MaxSendAttempts uint `json:"max_send_attempts"` + Contact string `json:"contact"` + ScheduledSendTime *time.Time `json:"scheduled_send_time"` + RequestReceivedAt time.Time `json:"request_received_at"` + Content string `json:"content"` + Attachments []entities.MessageAttachment `json:"attachments"` + Encrypted bool `json:"encrypted"` + SIM entities.SIM `json:"sim"` } diff --git a/api/pkg/handlers/discord_handler.go b/api/pkg/handlers/discord_handler.go index 95591c2b..729c6ce6 100644 --- a/api/pkg/handlers/discord_handler.go +++ b/api/pkg/handlers/discord_handler.go @@ -8,9 +8,11 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/google/uuid" + "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/requests" "github.com/NdoleStudio/httpsms/pkg/services" @@ -290,10 +292,30 @@ func (h *DiscordHandler) createRequest(payload map[string]any) requests.MessageS } return "" } + var attachments []entities.MessageAttachment + attachmentURLsStr := getOption("attachment_urls") + + if attachmentURLsStr != "" { + urls := strings.Split(attachmentURLsStr, ",") + for _, u := range urls { + cleanURL := strings.TrimSpace(u) + if cleanURL == "" { + continue + } + + contentType := entities.GetAttachmentContentType(cleanURL) + + attachments = append(attachments, entities.MessageAttachment{ + ContentType: contentType, + URL: cleanURL, + }) + } + } return requests.MessageSend{ - From: getOption("from"), - To: getOption("to"), - Content: getOption("message"), + From: getOption("from"), + To: getOption("to"), + Content: getOption("message"), + Attachments: attachments, } } @@ -341,6 +363,19 @@ func (h *DiscordHandler) sendSMS(ctx context.Context, c *fiber.Ctx, payload map[ }, } + if len(request.Attachments) > 0 { + var urls []string + for _, att := range request.Attachments { + urls = append(urls, att.URL) + } + + fields := messageEmbed["fields"].([]fiber.Map) + messageEmbed["fields"] = append(fields, fiber.Map{ + "name": "Attachments:", + "value": strings.Join(urls, "\n"), + }) + } + if errors := h.messageValidator.ValidateMessageSend(ctx, discord.UserID, request.Sanitize()); len(errors) != 0 { msg := fmt.Sprintf("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body()) ctxLogger.Warn(stacktrace.NewError(msg)) diff --git a/api/pkg/requests/bulk_message_request.go b/api/pkg/requests/bulk_message_request.go index ffb3f35c..a5aca7ce 100644 --- a/api/pkg/requests/bulk_message_request.go +++ b/api/pkg/requests/bulk_message_request.go @@ -18,6 +18,7 @@ type BulkMessage struct { ToPhoneNumber string `csv:"ToPhoneNumber"` Content string `csv:"Content"` SendTime *time.Time `csv:"SendTime(optional)"` + AttachmentURLs string `csv:"AttachmentURLs(optional)" validate:"optional"` // Comma separated list of URLs } // Sanitize sets defaults to BulkMessage @@ -25,12 +26,32 @@ func (input *BulkMessage) Sanitize() *BulkMessage { input.ToPhoneNumber = input.sanitizeAddress(input.ToPhoneNumber) input.Content = strings.TrimSpace(input.Content) input.FromPhoneNumber = input.sanitizeAddress(input.FromPhoneNumber) + input.AttachmentURLs = strings.TrimSpace(input.AttachmentURLs) return input } // ToMessageSendParams converts BulkMessage to services.MessageSendParams func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID uuid.UUID, source string) services.MessageSendParams { from, _ := phonenumbers.Parse(input.FromPhoneNumber, phonenumbers.UNKNOWN_REGION) + + var attachments []entities.MessageAttachment + if input.AttachmentURLs != "" { + urls := strings.Split(input.AttachmentURLs, ",") + for _, u := range urls { + cleanURL := strings.TrimSpace(u) + if cleanURL == "" { + continue + } + + contentType := entities.GetAttachmentContentType(cleanURL) + + attachments = append(attachments, entities.MessageAttachment{ + ContentType: contentType, + URL: cleanURL, + }) + } + } + return services.MessageSendParams{ Source: source, Owner: from, @@ -40,5 +61,6 @@ func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID RequestReceivedAt: time.Now().UTC(), Contact: input.sanitizeAddress(input.ToPhoneNumber), Content: input.Content, + Attachments: attachments, } } diff --git a/api/pkg/requests/message_bulk_send_request.go b/api/pkg/requests/message_bulk_send_request.go index a21570bb..47c2af7d 100644 --- a/api/pkg/requests/message_bulk_send_request.go +++ b/api/pkg/requests/message_bulk_send_request.go @@ -16,6 +16,7 @@ type MessageBulkSend struct { From string `json:"from" example:"+18005550199"` To []string `json:"to" example:"+18005550100,+18005550100"` Content string `json:"content" example:"This is a sample text message"` + Attachments []entities.MessageAttachment `json:"attachments" validate:"optional"` // Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app Encrypted bool `json:"encrypted" example:"false"` @@ -52,6 +53,7 @@ func (input *MessageBulkSend) ToMessageSendParams(userID entities.UserID, source Contact: to, SendAt: &sendAt, Content: input.Content, + Attachments: input.Attachments, }) } diff --git a/api/pkg/requests/message_send_request.go b/api/pkg/requests/message_send_request.go index 3301691d..1a1d6830 100644 --- a/api/pkg/requests/message_send_request.go +++ b/api/pkg/requests/message_send_request.go @@ -14,9 +14,10 @@ import ( // MessageSend is the payload for sending and SMS message type MessageSend struct { request - From string `json:"from" example:"+18005550199"` - To string `json:"to" example:"+18005550100"` - Content string `json:"content" example:"This is a sample text message"` + From string `json:"from" example:"+18005550199"` + To string `json:"to" example:"+18005550100"` + Content string `json:"content" example:"This is a sample text message"` + Attachments []entities.MessageAttachment `json:"attachments" validate:"optional"` // Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app Encrypted bool `json:"encrypted" example:"false" validate:"optional"` @@ -47,5 +48,6 @@ func (input *MessageSend) ToMessageSendParams(userID entities.UserID, source str RequestReceivedAt: time.Now().UTC(), Contact: input.sanitizeAddress(input.To), Content: input.Content, + Attachments: input.Attachments, } } diff --git a/api/pkg/services/discord_service.go b/api/pkg/services/discord_service.go index 059231b9..8c608e9f 100644 --- a/api/pkg/services/discord_service.go +++ b/api/pkg/services/discord_service.go @@ -169,6 +169,12 @@ func (service *DiscordService) createSlashCommand(ctx context.Context, serverID Type: 3, Required: true, }, + { + Name: "attachment_urls", + Description: "Comma-separated list of media URLs to attach", + Type: 3, + Required: false, + }, }, }) if err != nil { diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 5a95b265..eca2334c 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -430,6 +430,7 @@ type MessageSendParams struct { Contact string Encrypted bool Content string + Attachments []entities.MessageAttachment Source string SendAt *time.Time RequestID *string @@ -456,6 +457,7 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe Contact: params.Contact, RequestReceivedAt: params.RequestReceivedAt, Content: params.Content, + Attachments: params.Attachments, ScheduledSendTime: params.SendAt, SIM: sim, } @@ -968,6 +970,7 @@ func (service *MessageService) storeSentMessage(ctx context.Context, payload eve Contact: payload.Contact, UserID: payload.UserID, Content: payload.Content, + Attachments: payload.Attachments, RequestID: payload.RequestID, SIM: payload.SIM, Encrypted: payload.Encrypted, diff --git a/api/pkg/validators/bulk_message_handler_validator.go b/api/pkg/validators/bulk_message_handler_validator.go index 9881c53f..137442d0 100644 --- a/api/pkg/validators/bulk_message_handler_validator.go +++ b/api/pkg/validators/bulk_message_handler_validator.go @@ -17,6 +17,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/requests" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/dustin/go-humanize" "github.com/jszwec/csvutil" "github.com/nyaruka/phonenumbers" @@ -30,6 +31,7 @@ type BulkMessageHandlerValidator struct { userService *services.UserService logger telemetry.Logger tracer telemetry.Tracer + cache cache.Cache } // NewBulkMessageHandlerValidator creates a new handlers.BulkMessageHandlerValidator validator @@ -38,12 +40,14 @@ func NewBulkMessageHandlerValidator( tracer telemetry.Tracer, phoneService *services.PhoneService, userService *services.UserService, + appCache cache.Cache, ) (v *BulkMessageHandlerValidator) { return &BulkMessageHandlerValidator{ logger: logger.WithService(fmt.Sprintf("%T", v)), tracer: tracer, userService: userService, phoneService: phoneService, + cache: appCache, } } @@ -79,7 +83,7 @@ func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID messages[index] = message.Sanitize() } - result = v.validateMessages(messages) + result = v.validateMessages(ctx, messages) if len(result) != 0 { return messages, result } @@ -143,11 +147,17 @@ func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, user } } + var attachmentURLs string + if len(row) > 4 && strings.TrimSpace(row[4]) != "" { + attachmentURLs = strings.TrimSpace(row[4]) + } + messages = append(messages, &requests.BulkMessage{ FromPhoneNumber: strings.TrimSpace(row[0]), ToPhoneNumber: strings.TrimSpace(row[1]), Content: row[2], SendTime: sendAt, + AttachmentURLs: attachmentURLs, }) } @@ -209,9 +219,36 @@ func (v *BulkMessageHandlerValidator) parseCSV(ctxLogger telemetry.Logger, user return messages, url.Values{} } -func (v *BulkMessageHandlerValidator) validateMessages(messages []*requests.BulkMessage) url.Values { +func (v *BulkMessageHandlerValidator) validateMessages(ctx context.Context, messages []*requests.BulkMessage) url.Values { result := url.Values{} for index, message := range messages { + + if message.AttachmentURLs != "" { + urls := strings.Split(message.AttachmentURLs, ",") + + if len(urls) > 10 { + result.Add("document", fmt.Sprintf("Row [%d]: You cannot attach more than 10 files per message.", index+2)) + } + + for _, u := range urls { + cleanURL := strings.TrimSpace(u) + if cleanURL == "" { + continue + } + + parsedURL, err := url.ParseRequestURI(cleanURL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + result.Add("document", fmt.Sprintf("Row [%d]: The attachment URL [%s] has an invalid url format.", index+2, cleanURL)) + } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + result.Add("document", fmt.Sprintf("Row [%d]: The attachment URL [%s] must use http or https.", index+2, cleanURL)) + } else { + if err := validateAttachmentURL(ctx, v.cache, cleanURL); err != nil { + result.Add("attachments", fmt.Sprintf("Row [%d]: The attachment URL [%s] failed validation: %s", index+2, cleanURL, err.Error())) + } + } + } + } + if _, err := phonenumbers.Parse(message.FromPhoneNumber, phonenumbers.UNKNOWN_REGION); err != nil { result.Add("document", fmt.Sprintf("Row [%d]: The FromPhoneNumber [%s] is not a valid E.164 phone number", index+2, message.FromPhoneNumber)) } diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index 33ab4dfe..52741891 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/palantir/stacktrace" @@ -25,6 +26,7 @@ type MessageHandlerValidator struct { tracer telemetry.Tracer phoneService *services.PhoneService tokenValidator *TurnstileTokenValidator + cache cache.Cache } // NewMessageHandlerValidator creates a new handlers.MessageHandler validator @@ -33,12 +35,14 @@ func NewMessageHandlerValidator( tracer telemetry.Tracer, phoneService *services.PhoneService, tokenValidator *TurnstileTokenValidator, + appCache cache.Cache, ) (v *MessageHandlerValidator) { return &MessageHandlerValidator{ logger: logger.WithService(fmt.Sprintf("%T", v)), tracer: tracer, phoneService: phoneService, tokenValidator: tokenValidator, + cache: appCache, } } @@ -106,6 +110,31 @@ func (validator MessageHandlerValidator) ValidateMessageSend(ctx context.Context return result } + if len(request.Attachments) > 10 { + result.Add("attachments", "you cannot attach more than 10 files to a single message") + } + + for i, attachment := range request.Attachments { + if strings.TrimSpace(attachment.ContentType) == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d is missing content_type", i)) + } + + if strings.TrimSpace(attachment.URL) == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d is missing url", i)) + } else { + parsedURL, err := url.ParseRequestURI(attachment.URL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d has an invalid url format", i)) + } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + result.Add("attachments", fmt.Sprintf("attachment at index %d must use http or https scheme", i)) + } else { + if err := validateAttachmentURL(ctx, validator.cache, attachment.URL); err != nil { + result.Add("attachments", fmt.Sprintf("attachment at index %d failed validation: %s", i, err.Error())) + } + } + } + } + if request.SendAt != nil && request.SendAt.After(time.Now().Add(480*time.Hour)) { result.Add("send_at", "the scheduled time cannot be more than 20 days (480 hours) in the future") } @@ -156,6 +185,31 @@ func (validator MessageHandlerValidator) ValidateMessageBulkSend(ctx context.Con return result } + if len(request.Attachments) > 10 { + result.Add("attachments", "you cannot attach more than 10 files to a single message") + } + + for i, attachment := range request.Attachments { + if strings.TrimSpace(attachment.ContentType) == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d is missing content_type", i)) + } + + if strings.TrimSpace(attachment.URL) == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d is missing url", i)) + } else { + parsedURL, err := url.ParseRequestURI(attachment.URL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + result.Add("attachments", fmt.Sprintf("attachment at index %d has an invalid url format", i)) + } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + result.Add("attachments", fmt.Sprintf("attachment at index %d must use http or https scheme", i)) + } else { + if err := validateAttachmentURL(ctx, validator.cache, attachment.URL); err != nil { + result.Add("attachments", fmt.Sprintf("attachment at index %d failed validation: %s", i, err.Error())) + } + } + } + } + _, err := validator.phoneService.Load(ctx, userID, request.From) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { result.Add("from", fmt.Sprintf("no phone found with with 'from' number [%s]. Install the android app on your phone to start sending messages", request.From)) diff --git a/api/pkg/validators/validator.go b/api/pkg/validators/validator.go index bc7111e8..3cd85de2 100644 --- a/api/pkg/validators/validator.go +++ b/api/pkg/validators/validator.go @@ -1,11 +1,15 @@ package validators import ( + "context" "fmt" + "net/http" "net/url" "regexp" "strings" + "time" + "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/httpsms/pkg/events" "github.com/nyaruka/phonenumbers" @@ -160,3 +164,54 @@ func (validator *validator) ValidateUUID(ID string, name string) url.Values { return v.ValidateStruct() } + +func validateAttachmentURL(ctx context.Context, c cache.Cache, attachmentURL string) error { + cacheKey := "mms-url-validation:" + attachmentURL + + if cachedVal, err := c.Get(ctx, cacheKey); err == nil { + if cachedVal == "valid" { + return nil + } + return fmt.Errorf(cachedVal) + } + + client := &http.Client{ + Timeout: 5 * time.Second, + } + + req, err := http.NewRequest(http.MethodHead, attachmentURL, nil) + if err != nil { + errMsg := fmt.Sprintf("invalid url format") + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) + } + + resp, err := client.Do(req) + if err != nil { + errMsg := fmt.Sprintf("could not reach the url") + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + errMsg := fmt.Sprintf("url returned an error status code: %d", resp.StatusCode) + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) + } + + const maxSizeBytes = 1.5 * 1024 * 1024 + + if resp.ContentLength > int64(maxSizeBytes) { + errMsg := fmt.Sprintf("file size (%.2f MB) exceeds the 1.5 MB carrier limit", float64(resp.ContentLength)/(1024*1024)) + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) + } + + saveToCache(ctx, c, cacheKey, "valid") + return nil +} + +func saveToCache(ctx context.Context, c cache.Cache, key string, value string) { + _ = c.Set(ctx, key, value, 15*time.Minute) +} diff --git a/web/components/MessageThread.vue b/web/components/MessageThread.vue index 0f0f222b..51676b85 100644 --- a/web/components/MessageThread.vue +++ b/web/components/MessageThread.vue @@ -95,8 +95,10 @@ {{ thread.contact | phoneNumber }} - - {{ thread.last_message_content }} + + + {{ thread.last_message_content }} + @@ -150,6 +152,7 @@ import { mdiCheck, mdiAlert, mdiAccount, + mdiPaperclip, } from '@mdi/js' @Component @@ -160,6 +163,7 @@ export default class MessageThread extends Vue { mdiAlert = mdiAlert mdiCheck = mdiCheck mdiCheckAll = mdiCheckAll + mdiPaperclip = mdiPaperclip get threads(): Array { return this.$store.getters.getThreads diff --git a/web/models/message.ts b/web/models/message.ts index 35306648..c0d09f12 100644 --- a/web/models/message.ts +++ b/web/models/message.ts @@ -1,6 +1,12 @@ +export interface MessageAttachment { + content_type: string + url: string +} + export interface Message { contact: string content: string + attachments?: MessageAttachment[] created_at: string failure_reason: string id: string diff --git a/web/pages/messages/index.vue b/web/pages/messages/index.vue index a1a2a154..78a46533 100644 --- a/web/pages/messages/index.vue +++ b/web/pages/messages/index.vue @@ -33,6 +33,16 @@ placeholder="Enter your message here" label="Content" > + { @@ -113,6 +144,9 @@ export default { ), ) } + if (response.data.data.attachments) { + errors.set('attachments', response.data.data.attachments) + } if (response.data.data.from) { this.$store.dispatch('addNotification', { message: response.data.data.from[0], diff --git a/web/pages/threads/_id/index.vue b/web/pages/threads/_id/index.vue index 06042a45..21ff39c7 100644 --- a/web/pages/threads/_id/index.vue +++ b/web/pages/threads/_id/index.vue @@ -173,6 +173,46 @@ > + + Message Attachments + + + + + + + +
+ +
Unsupported file type
+
+
+
+
+

{{ new Date(message.order_timestamp).toLocaleString() }} diff --git a/web/static/templates/httpsms-bulk.csv b/web/static/templates/httpsms-bulk.csv index 38891f63..715ecab4 100644 --- a/web/static/templates/httpsms-bulk.csv +++ b/web/static/templates/httpsms-bulk.csv @@ -1,3 +1,3 @@ -FromPhoneNumber,ToPhoneNumber,Content -+18005550199,+18005550100,This is a sample text message1 -+18005550199,+18005550100,This is a sample text message2 +FromPhoneNumber,ToPhoneNumber,Content,SendTime(optional),AttachmentURLs(optional) ++18005550199,+18005550100,This is a sample text message1,,http://thisisasample.com/attachment.png ++18005550199,+18005550100,This is a sample text message2,2023-11-11T02:10:01, diff --git a/web/static/templates/httpsms-bulk.xlsx b/web/static/templates/httpsms-bulk.xlsx index bb713087..20f2b543 100644 Binary files a/web/static/templates/httpsms-bulk.xlsx and b/web/static/templates/httpsms-bulk.xlsx differ