diff --git a/README.md b/README.md
index ccae7451..84b77a40 100644
--- a/README.md
+++ b/README.md
@@ -37,11 +37,12 @@ Quick Start Guide 👉 [https://docs.httpsms.com](https://docs.httpsms.com)
- [Self Host Setup - Docker](#self-host-setup---docker)
- [1. Setup Firebase](#1-setup-firebase)
- [2. Setup SMTP Email service](#2-setup-smtp-email-service)
- - [3. Download the code](#3-download-the-code)
- - [4. Setup the environment variables](#4-setup-the-environment-variables)
- - [5. Build and Run](#5-build-and-run)
- - [6. Create the System User](#6-create-the-system-user)
- - [7. Build the Android App.](#7-build-the-android-app)
+ - [3. Setup Cloudflare Turnstile](#3-setup-cloudflare-turnstile)
+ - [4. Download the code](#4-download-the-code)
+ - [5. Setup the environment variables](#5-setup-the-environment-variables)
+ - [6. Build and Run](#6-build-and-run)
+ - [7. Create the System User](#7-create-the-system-user)
+ - [8. Build the Android App.](#8-build-the-android-app)
- [License](#license)
@@ -164,7 +165,15 @@ const firebaseConfig = {
The httpSMS application uses [SMTP](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol) to send emails to users e.g. when your Android phone has been offline for a long period of time.
You can use a service like [mailtrap](https://mailtrap.io/) to create an SMTP server for development purposes.
-### 3. Download the code
+### 3. Setup Cloudflare Turnstile
+
+The message search route (`/v1/messages/search`) is protected by a [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/get-started/) captcha to prevent abuse. You need to set up a Turnstile widget for the search messages feature to work.
+
+1. Go to the [Cloudflare dashboard](https://dash.cloudflare.com/) and navigate to **Turnstile**.
+2. Add a new site and configure it for your self-hosted domain (e.g., `localhost` for local development).
+3. Note down the **Site Key** and **Secret Key** — you will need them for the frontend and backend environment variables respectively.
+
+### 4. Download the code
Clone the httpSMS GitHub repository
@@ -172,7 +181,7 @@ Clone the httpSMS GitHub repository
git clone https://github.com/NdoleStudio/httpsms.git
```
-### 4. Setup the environment variables
+### 5. Setup the environment variables
- Copy the `.env.docker` file in the `web` directory into `.env`
@@ -190,6 +199,9 @@ FIREBASE_STORAGE_BUCKET=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
FIREBASE_MEASUREMENT_ID=
+
+# Cloudflare Turnstile site key from step 3
+CLOUDFLARE_TURNSTILE_SITE_KEY=
```
- Copy the `.env.docker` file in the `api` directory into `.env`
@@ -198,7 +210,7 @@ FIREBASE_MEASUREMENT_ID=
cp api/.env.docker api/.env
```
-- Update the environment variables in the `.env` file in the `api` directory with your firebase service account credentials and SMTP server details.
+- Update the environment variables in the `.env` file in the `api` directory with your firebase service account credentials, SMTP server details, and Cloudflare Turnstile secret key.
```dotenv
# SMTP email server settings
@@ -212,11 +224,14 @@ FIREBASE_CREDENTIALS=
# This is the `projectId` from your firebase web config
GCP_PROJECT_ID=
+
+# Cloudflare Turnstile secret key from step 3
+CLOUDFLARE_TURNSTILE_SECRET_KEY=
```
- Don't bother about the `EVENTS_QUEUE_USER_API_KEY` and `EVENTS_QUEUE_USER_ID` settings. We will set that up later.
-### 5. Build and Run
+### 6. Build and Run
- Build and run the API, the web UI, database and cache using the `docker-compose.yml` file. It takes a while for build and download all the docker images.
When it's finished, you'll be able to access the web UI at http://localhost:3000 and the API at http://localhost:8000
@@ -225,7 +240,7 @@ GCP_PROJECT_ID=
docker compose up --build
```
-### 6. Create the System User
+### 7. Create the System User
- The application uses the concept of a system user to process events async. You should manually create this user in `users` table in your database. Make sure you use the same `id` and `api_key` as the `EVENTS_QUEUE_USER_ID`, and `EVENTS_QUEUE_USER_API_KEY` in your `.env` file.
@@ -236,7 +251,7 @@ docker compose up --build
> [!IMPORTANT]
> Restart your API docker container after modifying `EVENTS_QUEUE_USER_ID`, and `EVENTS_QUEUE_USER_API_KEY` in your `.env` file so that the httpSMS API can pick up the changes.
-### 7. Build the Android App.
+### 8. Build the Android App.
- Before building the Android app in [Android Studio](https://developer.android.com/studio), you need to replace the `google-services.json` file in the `android/app` directory with the file which you got from step 1. You need to do this for the firebase FCM messages to work properly.
diff --git a/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt b/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt
index ab83a3ec..174f2742 100644
--- a/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt
+++ b/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt
@@ -29,11 +29,16 @@ class HeartbeatWorker(appContext: Context, workerParams: WorkerParameters) : Wor
return Result.success()
}
- HttpSmsApiService.create(applicationContext).storeHeartbeat(phoneNumbers.toTypedArray(), Settings.isCharging(applicationContext))
- Timber.d("finished sending heartbeats to server")
+ try{
+ HttpSmsApiService.create(applicationContext).storeHeartbeat(phoneNumbers.toTypedArray(), Settings.isCharging(applicationContext))
+ Timber.d("finished sending heartbeats to server")
- Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis())
- Timber.d("Set the heartbeat timestamp")
+ Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis())
+ Timber.d("Set the heartbeat timestamp")
+ } catch (exception: Exception) {
+ Timber.e(exception, "Failed to send [${phoneNumbers.joinToString()}] heartbeats to server")
+ return Result.failure()
+ }
return Result.success()
}
diff --git a/android/app/src/main/res/layout/activity_login.xml b/android/app/src/main/res/layout/activity_login.xml
index 25a19468..03552be5 100644
--- a/android/app/src/main/res/layout/activity_login.xml
+++ b/android/app/src/main/res/layout/activity_login.xml
@@ -1,175 +1,188 @@
-
-
-
-
-
-
-
+
+
-
-
+
+
+
+
-
-
+ android:layout_marginTop="32dp"
+ android:layout_marginBottom="24dp"
+ android:autoLink="web"
+ android:lineHeight="28sp"
+ android:text="@string/get_your_api_key"
+ android:textAlignment="center"
+ android:textSize="20sp"
+ app:layout_constraintBottom_toTopOf="@+id/loginApiKeyTextInputLayout"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/imageView"
+ app:layout_constraintVertical_bias="0"
+ app:layout_constraintVertical_chainStyle="packed" />
-
-
-
+ android:hint="@string/text_area_api_key"
+ app:errorEnabled="true"
+ app:endIconMode="custom"
+ app:endIconDrawable="@android:drawable/ic_menu_camera"
+ app:endIconContentDescription="cameraButton"
+ app:layout_constraintBottom_toTopOf="@+id/loginPhoneNumberLayoutSIM1"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/textView">
-
+
-
-
-
+
+
+ android:layout_marginTop="8dp"
+ android:hint="@string/login_phone_number_sim1"
+ app:errorEnabled="true"
+ app:placeholderText="@string/login_phone_number_hint"
+ app:layout_constraintBottom_toTopOf="@+id/loginPhoneNumberLayoutSIM2"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/loginApiKeyTextInputLayout">
-
+
+
+
-
+ app:placeholderText="@string/login_phone_number_hint"
+ app:layout_constraintBottom_toTopOf="@+id/loginServerUrlLayoutContainer"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/loginPhoneNumberLayoutSIM1">
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+ android:layout_marginTop="16dp"
+ android:orientation="vertical"
+ android:gravity="center"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/loginServerUrlLayoutContainer">
+
+
-
+
+
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 81a0f4f8..02dfa1b6 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -7,7 +7,7 @@
Login With API Key
API Key
HTTP Sms Logo
- Open\nhttpsms.com/settings\nto get your API key
+ Get Your API Key at\nhttpsms.com/settings
Log Out
e.g +18005550199 (international format)
e.g https://api.httpsms.com
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 095d0884..32077a01 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -10,8 +10,8 @@ buildscript {
}
plugins {
- id("com.android.application") version "9.1.0" apply false
- id("com.android.library") version "9.1.0" apply false
+ id("com.android.application") version "9.1.1" apply false
+ id("com.android.library") version "9.1.1" apply false
}
tasks.register("clean") {
diff --git a/api/.env.docker b/api/.env.docker
index 9dc43fdb..5441d7fe 100644
--- a/api/.env.docker
+++ b/api/.env.docker
@@ -48,6 +48,9 @@ DATABASE_URL_DEDICATED=postgresql://dbusername:dbpassword@postgres:5432/httpsms
# Redis connection string
REDIS_URL=redis://@redis:6379
+# Google Cloud Storage bucket for MMS attachments. Leave empty to use in-memory storage.
+GCS_BUCKET_NAME=
+
# [optional] If you would like to use uptrace.dev for distributed tracing, you can set the DSN here.
# This is optional and you can leave it empty if you don't want to use uptrace
UPTRACE_DSN=
@@ -58,3 +61,7 @@ PUSHER_APP_ID=
PUSHER_KEY=
PUSHER_SECRET=
PUSHER_CLUSTER=
+
+# Cloudflare Turnstile secret key for validating captcha tokens on the /v1/messages/search route
+# Get your secret key at https://developers.cloudflare.com/turnstile/get-started/
+CLOUDFLARE_TURNSTILE_SECRET_KEY=
diff --git a/api/docs/docs.go b/api/docs/docs.go
index 26545bdb..ab5a4ea6 100644
--- a/api/docs/docs.go
+++ b/api/docs/docs.go
@@ -1,5 +1,4 @@
-// Package docs GENERATED BY SWAG; DO NOT EDIT
-// This file was generated by swaggo/swag
+// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
@@ -1429,6 +1428,72 @@ const docTemplate = `{
}
},
"/messages/{messageID}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get a message from the database by the message ID.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Get a message from the database.",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the message",
+ "name": "messageID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
"delete": {
"security": [
{
@@ -2390,6 +2455,13 @@ const docTemplate = `{
"schema": {
"$ref": "#/definitions/requests.UserPaymentInvoice"
}
+ },
+ {
+ "type": "string",
+ "description": "ID of the subscription invoice to generate the PDF for",
+ "name": "subscriptionInvoiceID",
+ "in": "path",
+ "required": true
}
],
"responses": {
@@ -2611,6 +2683,68 @@ const docTemplate = `{
}
}
},
+ "/v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}": {
+ "get": {
+ "description": "Download an MMS attachment by its path components",
+ "produces": [
+ "application/octet-stream"
+ ],
+ "tags": [
+ "Attachments"
+ ],
+ "summary": "Download a message attachment",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "User ID",
+ "name": "userID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Message ID",
+ "name": "messageID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Attachment index",
+ "name": "attachmentIndex",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Filename with extension",
+ "name": "filename",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
"/webhooks": {
"get": {
"security": [
@@ -3014,6 +3148,7 @@ const docTemplate = `{
"entities.Message": {
"type": "object",
"required": [
+ "attachments",
"contact",
"content",
"created_at",
@@ -3031,6 +3166,16 @@ const docTemplate = `{
"user_id"
],
"properties": {
+ "attachments": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://example.com/image.jpg",
+ "https://example.com/video.mp4"
+ ]
+ },
"contact": {
"type": "string",
"example": "+18005550100"
@@ -3118,7 +3263,11 @@ const docTemplate = `{
},
"sim": {
"description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card",
- "type": "string",
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SIM"
+ }
+ ],
"example": "DEFAULT"
},
"status": {
@@ -3254,8 +3403,7 @@ const docTemplate = `{
"example": "+18005550199"
},
"sim": {
- "description": "SIM card that received the message",
- "type": "string"
+ "$ref": "#/definitions/entities.SIM"
},
"updated_at": {
"type": "string",
@@ -3331,6 +3479,46 @@ const docTemplate = `{
}
}
},
+ "entities.SIM": {
+ "type": "string",
+ "enum": [
+ "SIM1",
+ "SIM2"
+ ],
+ "x-enum-varnames": [
+ "SIM1",
+ "SIM2"
+ ]
+ },
+ "entities.SubscriptionName": {
+ "type": "string",
+ "enum": [
+ "free",
+ "pro-monthly",
+ "pro-yearly",
+ "ultra-monthly",
+ "ultra-yearly",
+ "pro-lifetime",
+ "20k-monthly",
+ "100k-monthly",
+ "50k-monthly",
+ "200k-monthly",
+ "20k-yearly"
+ ],
+ "x-enum-varnames": [
+ "SubscriptionNameFree",
+ "SubscriptionNameProMonthly",
+ "SubscriptionNameProYearly",
+ "SubscriptionNameUltraMonthly",
+ "SubscriptionNameUltraYearly",
+ "SubscriptionNameProLifetime",
+ "SubscriptionName20KMonthly",
+ "SubscriptionName100KMonthly",
+ "SubscriptionName50KMonthly",
+ "SubscriptionName200KMonthly",
+ "SubscriptionName20KYearly"
+ ]
+ },
"entities.User": {
"type": "object",
"required": [
@@ -3393,7 +3581,11 @@ const docTemplate = `{
"example": "8f9c71b8-b84e-4417-8408-a62274f65a08"
},
"subscription_name": {
- "type": "string",
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SubscriptionName"
+ }
+ ],
"example": "free"
},
"subscription_renews_at": {
@@ -3528,15 +3720,46 @@ const docTemplate = `{
}
}
},
+ "requests.MessageAttachment": {
+ "type": "object",
+ "required": [
+ "content",
+ "content_type",
+ "name"
+ ],
+ "properties": {
+ "content": {
+ "description": "Content is the base64-encoded attachment data",
+ "type": "string",
+ "example": "base64data..."
+ },
+ "content_type": {
+ "description": "ContentType is the MIME type of the attachment",
+ "type": "string",
+ "example": "image/jpeg"
+ },
+ "name": {
+ "description": "Name is the original filename of the attachment",
+ "type": "string",
+ "example": "photo.jpg"
+ }
+ }
+ },
"requests.MessageBulkSend": {
"type": "object",
"required": [
"content",
- "encrypted",
"from",
"to"
],
"properties": {
+ "attachments": {
+ "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"content": {
"type": "string",
"example": "This is a sample text message"
@@ -3629,6 +3852,13 @@ const docTemplate = `{
"to"
],
"properties": {
+ "attachments": {
+ "description": "Attachments is the list of MMS attachments received with the message",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/requests.MessageAttachment"
+ }
+ },
"content": {
"type": "string",
"example": "This is a sample text message received on a phone"
@@ -3644,7 +3874,11 @@ const docTemplate = `{
},
"sim": {
"description": "SIM card that received the message",
- "type": "string",
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SIM"
+ }
+ ],
"example": "SIM1"
},
"timestamp": {
@@ -3666,6 +3900,17 @@ const docTemplate = `{
"to"
],
"properties": {
+ "attachments": {
+ "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS",
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://example.com/image.jpg",
+ "https://example.com/video.mp4"
+ ]
+ },
"content": {
"type": "string",
"example": "This is a sample text message"
@@ -4612,6 +4857,8 @@ var SwaggerInfo = &swag.Spec{
Description: "Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption.",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
+ LeftDelim: "{{",
+ RightDelim: "}}",
}
func init() {
diff --git a/api/docs/swagger.json b/api/docs/swagger.json
index db16ea4b..b8bc5739 100644
--- a/api/docs/swagger.json
+++ b/api/docs/swagger.json
@@ -1297,6 +1297,66 @@
}
},
"/messages/{messageID}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get a message from the database by the message ID.",
+ "consumes": ["application/json"],
+ "produces": ["application/json"],
+ "tags": ["Messages"],
+ "summary": "Get a message from the database.",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the message",
+ "name": "messageID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
"delete": {
"security": [
{
@@ -2166,6 +2226,13 @@
"schema": {
"$ref": "#/definitions/requests.UserPaymentInvoice"
}
+ },
+ {
+ "type": "string",
+ "description": "ID of the subscription invoice to generate the PDF for",
+ "name": "subscriptionInvoiceID",
+ "in": "path",
+ "required": true
}
],
"responses": {
@@ -2369,6 +2436,64 @@
}
}
},
+ "/v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}": {
+ "get": {
+ "description": "Download an MMS attachment by its path components",
+ "produces": ["application/octet-stream"],
+ "tags": ["Attachments"],
+ "summary": "Download a message attachment",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "User ID",
+ "name": "userID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Message ID",
+ "name": "messageID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Attachment index",
+ "name": "attachmentIndex",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Filename with extension",
+ "name": "filename",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
"/webhooks": {
"get": {
"security": [
@@ -2748,6 +2873,7 @@
"entities.Message": {
"type": "object",
"required": [
+ "attachments",
"contact",
"content",
"created_at",
@@ -2765,6 +2891,16 @@
"user_id"
],
"properties": {
+ "attachments": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://example.com/image.jpg",
+ "https://example.com/video.mp4"
+ ]
+ },
"contact": {
"type": "string",
"example": "+18005550100"
@@ -2852,7 +2988,11 @@
},
"sim": {
"description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card",
- "type": "string",
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SIM"
+ }
+ ],
"example": "DEFAULT"
},
"status": {
@@ -2988,8 +3128,7 @@
"example": "+18005550199"
},
"sim": {
- "description": "SIM card that received the message",
- "type": "string"
+ "$ref": "#/definitions/entities.SIM"
},
"updated_at": {
"type": "string",
@@ -3062,6 +3201,40 @@
}
}
},
+ "entities.SIM": {
+ "type": "string",
+ "enum": ["SIM1", "SIM2"],
+ "x-enum-varnames": ["SIM1", "SIM2"]
+ },
+ "entities.SubscriptionName": {
+ "type": "string",
+ "enum": [
+ "free",
+ "pro-monthly",
+ "pro-yearly",
+ "ultra-monthly",
+ "ultra-yearly",
+ "pro-lifetime",
+ "20k-monthly",
+ "100k-monthly",
+ "50k-monthly",
+ "200k-monthly",
+ "20k-yearly"
+ ],
+ "x-enum-varnames": [
+ "SubscriptionNameFree",
+ "SubscriptionNameProMonthly",
+ "SubscriptionNameProYearly",
+ "SubscriptionNameUltraMonthly",
+ "SubscriptionNameUltraYearly",
+ "SubscriptionNameProLifetime",
+ "SubscriptionName20KMonthly",
+ "SubscriptionName100KMonthly",
+ "SubscriptionName50KMonthly",
+ "SubscriptionName200KMonthly",
+ "SubscriptionName20KYearly"
+ ]
+ },
"entities.User": {
"type": "object",
"required": [
@@ -3124,7 +3297,11 @@
"example": "8f9c71b8-b84e-4417-8408-a62274f65a08"
},
"subscription_name": {
- "type": "string",
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SubscriptionName"
+ }
+ ],
"example": "free"
},
"subscription_renews_at": {
@@ -3243,10 +3420,38 @@
}
}
},
+ "requests.MessageAttachment": {
+ "type": "object",
+ "required": ["content", "content_type", "name"],
+ "properties": {
+ "content": {
+ "description": "Content is the base64-encoded attachment data",
+ "type": "string",
+ "example": "base64data..."
+ },
+ "content_type": {
+ "description": "ContentType is the MIME type of the attachment",
+ "type": "string",
+ "example": "image/jpeg"
+ },
+ "name": {
+ "description": "Name is the original filename of the attachment",
+ "type": "string",
+ "example": "photo.jpg"
+ }
+ }
+ },
"requests.MessageBulkSend": {
"type": "object",
- "required": ["content", "encrypted", "from", "to"],
+ "required": ["content", "from", "to"],
"properties": {
+ "attachments": {
+ "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"content": {
"type": "string",
"example": "This is a sample text message"
@@ -3320,6 +3525,13 @@
"type": "object",
"required": ["content", "encrypted", "from", "sim", "timestamp", "to"],
"properties": {
+ "attachments": {
+ "description": "Attachments is the list of MMS attachments received with the message",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/requests.MessageAttachment"
+ }
+ },
"content": {
"type": "string",
"example": "This is a sample text message received on a phone"
@@ -3335,7 +3547,11 @@
},
"sim": {
"description": "SIM card that received the message",
- "type": "string",
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SIM"
+ }
+ ],
"example": "SIM1"
},
"timestamp": {
@@ -3353,6 +3569,17 @@
"type": "object",
"required": ["content", "from", "to"],
"properties": {
+ "attachments": {
+ "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS",
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://example.com/image.jpg",
+ "https://example.com/video.mp4"
+ ]
+ },
"content": {
"type": "string",
"example": "This is a sample text message"
diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml
index e8b171eb..f5563f2a 100644
--- a/api/docs/swagger.yaml
+++ b/api/docs/swagger.yaml
@@ -102,6 +102,13 @@ definitions:
type: object
entities.Message:
properties:
+ attachments:
+ example:
+ - https://example.com/image.jpg
+ - https://example.com/video.mp4
+ items:
+ type: string
+ type: array
contact:
example: "+18005550100"
type: string
@@ -169,13 +176,14 @@ definitions:
example: "2022-06-05T14:26:09.527976+03:00"
type: string
sim:
+ allOf:
+ - $ref: "#/definitions/entities.SIM"
description: |-
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
* DEFAULT: used the default communication SIM card
example: DEFAULT
- type: string
status:
example: pending
type: string
@@ -189,6 +197,7 @@ definitions:
example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC
type: string
required:
+ - attachments
- contact
- content
- created_at
@@ -289,8 +298,7 @@ definitions:
example: "+18005550199"
type: string
sim:
- description: SIM card that received the message
- type: string
+ $ref: "#/definitions/entities.SIM"
updated_at:
example: "2022-06-05T14:26:10.303278+03:00"
type: string
@@ -356,6 +364,40 @@ definitions:
- user_email
- user_id
type: object
+ entities.SIM:
+ enum:
+ - SIM1
+ - SIM2
+ type: string
+ x-enum-varnames:
+ - SIM1
+ - SIM2
+ entities.SubscriptionName:
+ enum:
+ - free
+ - pro-monthly
+ - pro-yearly
+ - ultra-monthly
+ - ultra-yearly
+ - pro-lifetime
+ - 20k-monthly
+ - 100k-monthly
+ - 50k-monthly
+ - 200k-monthly
+ - 20k-yearly
+ type: string
+ x-enum-varnames:
+ - SubscriptionNameFree
+ - SubscriptionNameProMonthly
+ - SubscriptionNameProYearly
+ - SubscriptionNameUltraMonthly
+ - SubscriptionNameUltraYearly
+ - SubscriptionNameProLifetime
+ - SubscriptionName20KMonthly
+ - SubscriptionName100KMonthly
+ - SubscriptionName50KMonthly
+ - SubscriptionName200KMonthly
+ - SubscriptionName20KYearly
entities.User:
properties:
active_phone_id:
@@ -392,8 +434,9 @@ definitions:
example: 8f9c71b8-b84e-4417-8408-a62274f65a08
type: string
subscription_name:
+ allOf:
+ - $ref: "#/definitions/entities.SubscriptionName"
example: free
- type: string
subscription_renews_at:
example: "2022-06-05T14:26:02.302718+03:00"
type: string
@@ -501,8 +544,34 @@ definitions:
- charging
- phone_numbers
type: object
+ requests.MessageAttachment:
+ properties:
+ content:
+ description: Content is the base64-encoded attachment data
+ example: base64data...
+ type: string
+ content_type:
+ description: ContentType is the MIME type of the attachment
+ example: image/jpeg
+ type: string
+ name:
+ description: Name is the original filename of the attachment
+ example: photo.jpg
+ type: string
+ required:
+ - content
+ - content_type
+ - name
+ type: object
requests.MessageBulkSend:
properties:
+ attachments:
+ description:
+ Attachments are optional. When you provide a list of attachments,
+ the message will be sent out as an MMS
+ items:
+ type: string
+ type: array
content:
example: This is a sample text message
type: string
@@ -530,7 +599,6 @@ definitions:
type: array
required:
- content
- - encrypted
- from
- to
type: object
@@ -580,6 +648,13 @@ definitions:
type: object
requests.MessageReceive:
properties:
+ attachments:
+ description:
+ Attachments is the list of MMS attachments received with the
+ message
+ items:
+ $ref: "#/definitions/requests.MessageAttachment"
+ type: array
content:
example: This is a sample text message received on a phone
type: string
@@ -593,9 +668,10 @@ definitions:
example: "+18005550199"
type: string
sim:
+ allOf:
+ - $ref: "#/definitions/entities.SIM"
description: SIM card that received the message
example: SIM1
- type: string
timestamp:
description:
Timestamp is the time when the event was emitted, Please send
@@ -615,6 +691,16 @@ definitions:
type: object
requests.MessageSend:
properties:
+ attachments:
+ description:
+ Attachments are optional. When you provide a list of attachments,
+ the message will be sent out as an MMS
+ example:
+ - https://example.com/image.jpg
+ - https://example.com/video.mp4
+ items:
+ type: string
+ type: array
content:
example: This is a sample text message
type: string
@@ -2031,6 +2117,49 @@ paths:
summary: Delete a message from the database.
tags:
- Messages
+ get:
+ consumes:
+ - application/json
+ description: Get a message from the database by the message ID.
+ parameters:
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the message
+ in: path
+ name: messageID
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "204":
+ description: No Content
+ schema:
+ $ref: "#/definitions/responses.MessageResponse"
+ "400":
+ description: Bad Request
+ schema:
+ $ref: "#/definitions/responses.BadRequest"
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: "#/definitions/responses.Unauthorized"
+ "404":
+ description: Not Found
+ schema:
+ $ref: "#/definitions/responses.NotFound"
+ "422":
+ description: Unprocessable Entity
+ schema:
+ $ref: "#/definitions/responses.UnprocessableEntity"
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: "#/definitions/responses.InternalServerError"
+ security:
+ - ApiKeyAuth: []
+ summary: Get a message from the database.
+ tags:
+ - Messages
/messages/{messageID}/events:
post:
consumes:
@@ -2973,6 +3102,11 @@ paths:
required: true
schema:
$ref: "#/definitions/requests.UserPaymentInvoice"
+ - description: ID of the subscription invoice to generate the PDF for
+ in: path
+ name: subscriptionInvoiceID
+ required: true
+ type: string
produces:
- application/pdf
responses:
@@ -3037,6 +3171,48 @@ paths:
summary: Get the last 10 subscription payments.
tags:
- Users
+ /v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}:
+ get:
+ description: Download an MMS attachment by its path components
+ parameters:
+ - description: User ID
+ in: path
+ name: userID
+ required: true
+ type: string
+ - description: Message ID
+ in: path
+ name: messageID
+ required: true
+ type: string
+ - description: Attachment index
+ in: path
+ name: attachmentIndex
+ required: true
+ type: string
+ - description: Filename with extension
+ in: path
+ name: filename
+ required: true
+ type: string
+ produces:
+ - application/octet-stream
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: file
+ "404":
+ description: Not Found
+ schema:
+ $ref: "#/definitions/responses.NotFound"
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: "#/definitions/responses.InternalServerError"
+ summary: Download a message attachment
+ tags:
+ - Attachments
/webhooks:
get:
consumes:
diff --git a/api/go.mod b/api/go.mod
index 94ce0146..3c361930 100644
--- a/api/go.mod
+++ b/api/go.mod
@@ -4,6 +4,7 @@ go 1.25.0
require (
cloud.google.com/go/cloudtasks v1.14.0
+ cloud.google.com/go/storage v1.62.0
firebase.google.com/go v3.13.0+incompatible
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.31.0
@@ -50,6 +51,7 @@ require (
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
+ golang.org/x/sync v0.20.0
google.golang.org/api v0.274.0
google.golang.org/protobuf v1.36.11
gorm.io/driver/postgres v1.6.0
@@ -80,7 +82,6 @@ require (
cloud.google.com/go/iam v1.7.0 // indirect
cloud.google.com/go/longrunning v0.9.0 // indirect
cloud.google.com/go/monitoring v1.25.0 // indirect
- cloud.google.com/go/storage v1.61.3 // indirect
cloud.google.com/go/trace v1.12.0 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
@@ -133,7 +134,7 @@ require (
github.com/huandu/xstrings v1.5.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
- github.com/jackc/pgx/v5 v5.9.1 // indirect
+ github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -190,7 +191,6 @@ require (
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
- golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
diff --git a/api/go.sum b/api/go.sum
index f55f2c01..60df8733 100644
--- a/api/go.sum
+++ b/api/go.sum
@@ -22,8 +22,8 @@ cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8
cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E=
cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPkW7NK4=
cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc=
-cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg=
-cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=
+cloud.google.com/go/storage v1.62.0 h1:w2pQJhpUqVerMON45vatE2FpCYsNTf7OHjkn6ux5mMU=
+cloud.google.com/go/storage v1.62.0/go.mod h1:T5hz3qzcpnxZ5LdKc7y8Tw7lh4v9zeeVyrD/cLJAzZU=
cloud.google.com/go/trace v1.12.0 h1:XvWHYfr9q88cX4pZyou6qCcSagnuASyUq2ej1dB6NzQ=
cloud.google.com/go/trace v1.12.0/go.mod h1:TOYfyeoyCGsSH0ifXD6Aius24uQI9xV3RyvOdljFIyg=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
@@ -213,8 +213,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
-github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
+github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jaswdr/faker/v2 v2.9.1 h1:J0Rjqb2/FquZnoZplzkGVL5LmhNkeIpvsSMoJKzn+8E=
@@ -375,8 +375,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bT
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go
index 14fc9ea4..33d27b01 100644
--- a/api/pkg/di/container.go
+++ b/api/pkg/di/container.go
@@ -25,6 +25,7 @@ import (
"github.com/NdoleStudio/httpsms/pkg/discord"
+ "cloud.google.com/go/storage"
mexporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric"
cloudtrace "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace"
"github.com/NdoleStudio/httpsms/pkg/cache"
@@ -80,13 +81,14 @@ import (
// Container is used to resolve services at runtime
type Container struct {
- projectID string
- db *gorm.DB
- dedicatedDB *gorm.DB
- version string
- app *fiber.App
- eventDispatcher *services.EventDispatcher
- logger telemetry.Logger
+ projectID string
+ db *gorm.DB
+ dedicatedDB *gorm.DB
+ version string
+ app *fiber.App
+ eventDispatcher *services.EventDispatcher
+ logger telemetry.Logger
+ attachmentRepository repositories.AttachmentRepository
}
// NewLiteContainer creates a Container without any routes or listeners
@@ -118,6 +120,7 @@ func NewContainer(projectID string, version string) (container *Container) {
container.RegisterMessageListeners()
container.RegisterMessageRoutes()
+ container.RegisterAttachmentRoutes()
container.RegisterBulkMessageRoutes()
container.RegisterMessageThreadRoutes()
@@ -395,7 +398,7 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (
// FirebaseApp creates a new instance of firebase.App
func (container *Container) FirebaseApp() (app *firebase.App) {
container.logger.Debug(fmt.Sprintf("creating %T", app))
- app, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsJSON(container.FirebaseCredentials()))
+ app, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsJSON(option.ServiceAccount, container.FirebaseCredentials()))
if err != nil {
msg := "cannot initialize firebase application"
container.logger.Fatal(stacktrace.Propagate(err, msg))
@@ -1430,9 +1433,63 @@ func (container *Container) MessageService() (service *services.MessageService)
container.MessageRepository(),
container.EventDispatcher(),
container.PhoneService(),
+ container.AttachmentRepository(),
+ container.APIBaseURL(),
)
}
+// AttachmentRepository creates a cached AttachmentRepository based on configuration
+func (container *Container) AttachmentRepository() repositories.AttachmentRepository {
+ if container.attachmentRepository != nil {
+ return container.attachmentRepository
+ }
+
+ bucket := os.Getenv("GCS_BUCKET_NAME")
+ if bucket != "" {
+ container.logger.Debug("creating GoogleCloudStorageAttachmentRepository")
+ client, err := storage.NewClient(context.Background(), option.WithAuthCredentialsJSON(option.ServiceAccount, container.FirebaseCredentials()))
+ if err != nil {
+ container.logger.Fatal(stacktrace.Propagate(err, "cannot create GCS client"))
+ }
+ container.attachmentRepository = repositories.NewGoogleCloudStorageAttachmentRepository(
+ container.Logger(),
+ container.Tracer(),
+ client,
+ bucket,
+ )
+ } else {
+ container.logger.Debug("creating MemoryAttachmentRepository (GCS_BUCKET_NAME not set)")
+ container.attachmentRepository = repositories.NewMemoryAttachmentRepository(
+ container.Logger(),
+ container.Tracer(),
+ )
+ }
+
+ return container.attachmentRepository
+}
+
+// APIBaseURL returns the API base URL derived from EVENTS_QUEUE_ENDPOINT
+func (container *Container) APIBaseURL() string {
+ endpoint := os.Getenv("EVENTS_QUEUE_ENDPOINT")
+ return strings.TrimSuffix(endpoint, "/v1/events")
+}
+
+// AttachmentHandler creates a new AttachmentHandler
+func (container *Container) AttachmentHandler() (handler *handlers.AttachmentHandler) {
+ container.logger.Debug(fmt.Sprintf("creating %T", handler))
+ return handlers.NewAttachmentHandler(
+ container.Logger(),
+ container.Tracer(),
+ container.AttachmentRepository(),
+ )
+}
+
+// RegisterAttachmentRoutes registers routes for the /attachments prefix
+func (container *Container) RegisterAttachmentRoutes() {
+ container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.AttachmentHandler{}))
+ container.AttachmentHandler().RegisterRoutes(container.App())
+}
+
// PhoneAPIKeyService creates a new instance of services.PhoneAPIKeyService
func (container *Container) PhoneAPIKeyService() (service *services.PhoneAPIKeyService) {
container.logger.Debug(fmt.Sprintf("creating %T", service))
diff --git a/api/pkg/events/message_phone_received_event.go b/api/pkg/events/message_phone_received_event.go
index abe3a014..04dd6c2e 100644
--- a/api/pkg/events/message_phone_received_event.go
+++ b/api/pkg/events/message_phone_received_event.go
@@ -13,12 +13,13 @@ const EventTypeMessagePhoneReceived = "message.phone.received"
// MessagePhoneReceivedPayload is the payload of the EventTypeMessagePhoneReceived event
type MessagePhoneReceivedPayload struct {
- MessageID uuid.UUID `json:"message_id"`
- UserID entities.UserID `json:"user_id"`
- Owner string `json:"owner"`
- Encrypted bool `json:"encrypted"`
- Contact string `json:"contact"`
- Timestamp time.Time `json:"timestamp"`
- Content string `json:"content"`
- SIM entities.SIM `json:"sim"`
+ MessageID uuid.UUID `json:"message_id"`
+ UserID entities.UserID `json:"user_id"`
+ Owner string `json:"owner"`
+ Encrypted bool `json:"encrypted"`
+ Contact string `json:"contact"`
+ Timestamp time.Time `json:"timestamp"`
+ Content string `json:"content"`
+ SIM entities.SIM `json:"sim"`
+ Attachments []string `json:"attachments"`
}
diff --git a/api/pkg/handlers/attachment_handler.go b/api/pkg/handlers/attachment_handler.go
new file mode 100644
index 00000000..46a4397b
--- /dev/null
+++ b/api/pkg/handlers/attachment_handler.go
@@ -0,0 +1,85 @@
+package handlers
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/NdoleStudio/httpsms/pkg/repositories"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/gofiber/fiber/v2"
+ "github.com/palantir/stacktrace"
+)
+
+// AttachmentHandler handles attachment download requests
+type AttachmentHandler struct {
+ handler
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ storage repositories.AttachmentRepository
+}
+
+// NewAttachmentHandler creates a new AttachmentHandler
+func NewAttachmentHandler(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ storage repositories.AttachmentRepository,
+) (h *AttachmentHandler) {
+ return &AttachmentHandler{
+ logger: logger.WithService(fmt.Sprintf("%T", h)),
+ tracer: tracer,
+ storage: storage,
+ }
+}
+
+// RegisterRoutes registers the routes for the AttachmentHandler (no auth middleware — public endpoint)
+func (h *AttachmentHandler) RegisterRoutes(router fiber.Router) {
+ router.Get("/v1/attachments/:userID/:messageID/:attachmentIndex/:filename", h.GetAttachment)
+}
+
+// GetAttachment Downloads an attachment
+// @Summary Download a message attachment
+// @Description Download an MMS attachment by its path components
+// @Tags Attachments
+// @Produce application/octet-stream
+// @Param userID path string true "User ID"
+// @Param messageID path string true "Message ID"
+// @Param attachmentIndex path string true "Attachment index"
+// @Param filename path string true "Filename with extension"
+// @Success 200 {file} binary
+// @Failure 404 {object} responses.NotFound
+// @Failure 500 {object} responses.InternalServerError
+// @Router /v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename} [get]
+func (h *AttachmentHandler) GetAttachment(c *fiber.Ctx) error {
+ ctx, span := h.tracer.StartFromFiberCtx(c)
+ defer span.End()
+
+ ctxLogger := h.tracer.CtxLogger(h.logger, span)
+
+ userID := c.Params("userID")
+ messageID := c.Params("messageID")
+ attachmentIndex := c.Params("attachmentIndex")
+ filename := c.Params("filename")
+
+ path := fmt.Sprintf("attachments/%s/%s/%s/%s", userID, messageID, attachmentIndex, filename)
+
+ ctxLogger.Info(fmt.Sprintf("downloading attachment from path [%s]", path))
+
+ data, err := h.storage.Download(ctx, path)
+ if err != nil {
+ msg := fmt.Sprintf("cannot download attachment from path [%s]", path)
+ ctxLogger.Warn(stacktrace.Propagate(err, msg))
+ if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
+ return h.responseNotFound(c, "attachment not found")
+ }
+ return h.responseInternalServerError(c)
+ }
+
+ ext := filepath.Ext(filename)
+ contentType := repositories.ContentTypeFromExtension(ext)
+
+ c.Set("Content-Type", contentType)
+ c.Set("Content-Disposition", "attachment")
+ c.Set("X-Content-Type-Options", "nosniff")
+
+ return c.Send(data)
+}
diff --git a/api/pkg/repositories/attachment_repository.go b/api/pkg/repositories/attachment_repository.go
new file mode 100644
index 00000000..11d80e20
--- /dev/null
+++ b/api/pkg/repositories/attachment_repository.go
@@ -0,0 +1,99 @@
+package repositories
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "strings"
+)
+
+// AttachmentRepository is the interface for storing and retrieving message attachments
+type AttachmentRepository interface {
+ // Upload stores attachment data at the given path with the specified content type
+ Upload(ctx context.Context, path string, data []byte, contentType string) error
+ // Download retrieves attachment data from the given path
+ Download(ctx context.Context, path string) ([]byte, error)
+ // Delete removes an attachment at the given path
+ Delete(ctx context.Context, path string) error
+}
+
+// contentTypeExtensions maps MIME types to file extensions
+var contentTypeExtensions = map[string]string{
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+ "image/bmp": ".bmp",
+ "video/mp4": ".mp4",
+ "video/3gpp": ".3gp",
+ "audio/mpeg": ".mp3",
+ "audio/ogg": ".ogg",
+ "audio/amr": ".amr",
+ "application/pdf": ".pdf",
+ "text/vcard": ".vcf",
+ "text/x-vcard": ".vcf",
+}
+
+// extensionContentTypes is the reverse map from file extensions to canonical MIME types
+var extensionContentTypes = map[string]string{
+ ".jpg": "image/jpeg",
+ ".png": "image/png",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".bmp": "image/bmp",
+ ".mp4": "video/mp4",
+ ".3gp": "video/3gpp",
+ ".mp3": "audio/mpeg",
+ ".ogg": "audio/ogg",
+ ".amr": "audio/amr",
+ ".pdf": "application/pdf",
+ ".vcf": "text/vcard",
+}
+
+// AllowedContentTypes returns the set of allowed MIME types for attachments
+func AllowedContentTypes() map[string]bool {
+ allowed := make(map[string]bool, len(contentTypeExtensions))
+ for ct := range contentTypeExtensions {
+ allowed[ct] = true
+ }
+ return allowed
+}
+
+// ExtensionFromContentType returns the file extension for a MIME content type.
+// Returns ".bin" if the content type is not recognized.
+func ExtensionFromContentType(contentType string) string {
+ if ext, ok := contentTypeExtensions[contentType]; ok {
+ return ext
+ }
+ return ".bin"
+}
+
+// ContentTypeFromExtension returns the MIME content type for a file extension.
+// Returns "application/octet-stream" if the extension is not recognized.
+func ContentTypeFromExtension(ext string) string {
+ if ct, ok := extensionContentTypes[ext]; ok {
+ return ct
+ }
+ return "application/octet-stream"
+}
+
+// SanitizeFilename removes path separators and traversal sequences from a filename.
+// Returns "attachment-{index}" if the sanitized name is empty.
+func SanitizeFilename(name string, index int) string {
+ name = strings.TrimSuffix(name, filepath.Ext(name))
+
+ var builder strings.Builder
+ for _, r := range name {
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
+ builder.WriteRune(r)
+ } else if r == ' ' {
+ builder.WriteRune('-')
+ }
+ }
+ name = strings.Trim(builder.String(), "-")
+
+ if name == "" {
+ return fmt.Sprintf("attachment-%d", index)
+ }
+ return name
+}
diff --git a/api/pkg/repositories/attachment_repository_test.go b/api/pkg/repositories/attachment_repository_test.go
new file mode 100644
index 00000000..1b29fa68
--- /dev/null
+++ b/api/pkg/repositories/attachment_repository_test.go
@@ -0,0 +1,63 @@
+package repositories
+
+import "testing"
+
+func TestExtensionFromContentType(t *testing.T) {
+ tests := []struct {
+ contentType string
+ expected string
+ }{
+ {"image/jpeg", ".jpg"},
+ {"image/png", ".png"},
+ {"image/gif", ".gif"},
+ {"image/webp", ".webp"},
+ {"image/bmp", ".bmp"},
+ {"video/mp4", ".mp4"},
+ {"video/3gpp", ".3gp"},
+ {"audio/mpeg", ".mp3"},
+ {"audio/ogg", ".ogg"},
+ {"audio/amr", ".amr"},
+ {"application/pdf", ".pdf"},
+ {"text/vcard", ".vcf"},
+ {"text/x-vcard", ".vcf"},
+ {"application/octet-stream", ".bin"},
+ {"unknown/type", ".bin"},
+ {"", ".bin"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.contentType, func(t *testing.T) {
+ got := ExtensionFromContentType(tt.contentType)
+ if got != tt.expected {
+ t.Errorf("ExtensionFromContentType(%q) = %q, want %q", tt.contentType, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestSanitizeFilename(t *testing.T) {
+ tests := []struct {
+ name string
+ index int
+ expected string
+ }{
+ {"photo.jpg", 0, "photo"},
+ {"../../etc/passwd", 0, "etcpasswd"},
+ {"hello/world\\test", 0, "helloworldtest"},
+ {"normal_file", 0, "normal_file"},
+ {"", 0, "attachment-0"},
+ {" ", 0, "attachment-0"},
+ {"...", 1, "attachment-1"},
+ {"My Photo", 0, "My-Photo"},
+ {"file name with spaces.png", 0, "file-name-with-spaces"},
+ {"UPPER_CASE", 0, "UPPER_CASE"},
+ {"special!@#chars", 0, "specialchars"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := SanitizeFilename(tt.name, tt.index)
+ if got != tt.expected {
+ t.Errorf("SanitizeFilename(%q, %d) = %q, want %q", tt.name, tt.index, got, tt.expected)
+ }
+ })
+ }
+}
diff --git a/api/pkg/repositories/google_cloud_storage_attachment_repository.go b/api/pkg/repositories/google_cloud_storage_attachment_repository.go
new file mode 100644
index 00000000..d1e0eb92
--- /dev/null
+++ b/api/pkg/repositories/google_cloud_storage_attachment_repository.go
@@ -0,0 +1,92 @@
+package repositories
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+
+ "cloud.google.com/go/storage"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/palantir/stacktrace"
+)
+
+// GoogleCloudStorageAttachmentRepository stores attachments in Google Cloud Storage
+type GoogleCloudStorageAttachmentRepository struct {
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ client *storage.Client
+ bucket string
+}
+
+// NewGoogleCloudStorageAttachmentRepository creates a new GoogleCloudStorageAttachmentRepository
+func NewGoogleCloudStorageAttachmentRepository(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ client *storage.Client,
+ bucket string,
+) *GoogleCloudStorageAttachmentRepository {
+ return &GoogleCloudStorageAttachmentRepository{
+ logger: logger.WithService(fmt.Sprintf("%T", &GoogleCloudStorageAttachmentRepository{})),
+ tracer: tracer,
+ client: client,
+ bucket: bucket,
+ }
+}
+
+// Upload stores attachment data at the given path in GCS
+func (s *GoogleCloudStorageAttachmentRepository) Upload(ctx context.Context, path string, data []byte, contentType string) error {
+ ctx, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ writer := s.client.Bucket(s.bucket).Object(path).NewWriter(ctx)
+ writer.ContentType = contentType
+
+ if _, err := writer.Write(data); err != nil {
+ return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot write attachment to GCS path [%s]", path)))
+ }
+
+ if err := writer.Close(); err != nil {
+ return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot close GCS writer for path [%s]", path)))
+ }
+
+ ctxLogger.Info(fmt.Sprintf("uploaded attachment to GCS path [%s/%s] with size [%d]", s.bucket, path, len(data)))
+ return nil
+}
+
+// Download retrieves attachment data from the given path in GCS
+func (s *GoogleCloudStorageAttachmentRepository) Download(ctx context.Context, path string) ([]byte, error) {
+ ctx, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ reader, err := s.client.Bucket(s.bucket).Object(path).NewReader(ctx)
+ if err != nil {
+ msg := fmt.Sprintf("cannot open GCS reader for path [%s]", path)
+ if errors.Is(err, storage.ErrObjectNotExist) {
+ return nil, s.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg))
+ }
+ return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+ defer reader.Close()
+
+ data, err := io.ReadAll(reader)
+ if err != nil {
+ return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot read attachment from GCS path [%s]", path)))
+ }
+
+ ctxLogger.Info(fmt.Sprintf("downloaded attachment from GCS path [%s/%s] with size [%d]", s.bucket, path, len(data)))
+ return data, nil
+}
+
+// Delete removes an attachment at the given path in GCS
+func (s *GoogleCloudStorageAttachmentRepository) Delete(ctx context.Context, path string) error {
+ ctx, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ if err := s.client.Bucket(s.bucket).Object(path).Delete(ctx); err != nil {
+ return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot delete GCS object at path [%s]", path)))
+ }
+
+ ctxLogger.Info(fmt.Sprintf("deleted attachment from GCS path [%s/%s]", s.bucket, path))
+ return nil
+}
diff --git a/api/pkg/repositories/memory_attachment_repository.go b/api/pkg/repositories/memory_attachment_repository.go
new file mode 100644
index 00000000..65eadf2f
--- /dev/null
+++ b/api/pkg/repositories/memory_attachment_repository.go
@@ -0,0 +1,60 @@
+package repositories
+
+import (
+ "context"
+ "fmt"
+ "sync"
+
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/palantir/stacktrace"
+)
+
+// MemoryAttachmentRepository stores attachments in memory
+type MemoryAttachmentRepository struct {
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ data sync.Map
+}
+
+// NewMemoryAttachmentRepository creates a new MemoryAttachmentRepository
+func NewMemoryAttachmentRepository(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+) *MemoryAttachmentRepository {
+ return &MemoryAttachmentRepository{
+ logger: logger.WithService(fmt.Sprintf("%T", &MemoryAttachmentRepository{})),
+ tracer: tracer,
+ }
+}
+
+// Upload stores attachment data at the given path
+func (s *MemoryAttachmentRepository) Upload(ctx context.Context, path string, data []byte, _ string) error {
+ _, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ s.data.Store(path, data)
+ ctxLogger.Info(fmt.Sprintf("stored attachment at path [%s] with size [%d]", path, len(data)))
+ return nil
+}
+
+// Download retrieves attachment data from the given path
+func (s *MemoryAttachmentRepository) Download(ctx context.Context, path string) ([]byte, error) {
+ _, span, _ := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ value, ok := s.data.Load(path)
+ if !ok {
+ return nil, s.tracer.WrapErrorSpan(span, stacktrace.NewErrorWithCode(ErrCodeNotFound, fmt.Sprintf("attachment not found at path [%s]", path)))
+ }
+ return value.([]byte), nil
+}
+
+// Delete removes an attachment at the given path
+func (s *MemoryAttachmentRepository) Delete(ctx context.Context, path string) error {
+ _, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ s.data.Delete(path)
+ ctxLogger.Info(fmt.Sprintf("deleted attachment at path [%s]", path))
+ return nil
+}
diff --git a/api/pkg/requests/message_receive_request.go b/api/pkg/requests/message_receive_request.go
index f592761c..b89cddfa 100644
--- a/api/pkg/requests/message_receive_request.go
+++ b/api/pkg/requests/message_receive_request.go
@@ -11,6 +11,16 @@ import (
"github.com/NdoleStudio/httpsms/pkg/services"
)
+// MessageAttachment represents a single MMS attachment in a receive request
+type MessageAttachment struct {
+ // Name is the original filename of the attachment
+ Name string `json:"name" example:"photo.jpg"`
+ // ContentType is the MIME type of the attachment
+ ContentType string `json:"content_type" example:"image/jpeg"`
+ // Content is the base64-encoded attachment data
+ Content string `json:"content" example:"base64data..."`
+}
+
// MessageReceive is the payload for sending and SMS message
type MessageReceive struct {
request
@@ -23,6 +33,8 @@ type MessageReceive struct {
SIM entities.SIM `json:"sim" example:"SIM1"`
// Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible
Timestamp time.Time `json:"timestamp" example:"2022-06-05T14:26:09.527976+03:00"`
+ // Attachments is the list of MMS attachments received with the message
+ Attachments []MessageAttachment `json:"attachments" validate:"optional"`
}
// Sanitize sets defaults to MessageReceive
@@ -38,14 +50,25 @@ func (input *MessageReceive) Sanitize() MessageReceive {
// ToMessageReceiveParams converts MessageReceive to services.MessageReceiveParams
func (input *MessageReceive) ToMessageReceiveParams(userID entities.UserID, source string) *services.MessageReceiveParams {
phone, _ := phonenumbers.Parse(input.To, phonenumbers.UNKNOWN_REGION)
+
+ attachments := make([]services.ServiceAttachment, len(input.Attachments))
+ for i, a := range input.Attachments {
+ attachments[i] = services.ServiceAttachment{
+ Name: a.Name,
+ ContentType: a.ContentType,
+ Content: a.Content,
+ }
+ }
+
return &services.MessageReceiveParams{
- Source: source,
- Contact: input.From,
- UserID: userID,
- Timestamp: input.Timestamp,
- Encrypted: input.Encrypted,
- Owner: *phone,
- Content: input.Content,
- SIM: input.SIM,
+ Source: source,
+ Contact: input.From,
+ UserID: userID,
+ Timestamp: input.Timestamp,
+ Encrypted: input.Encrypted,
+ Owner: *phone,
+ Content: input.Content,
+ SIM: input.SIM,
+ Attachments: attachments,
}
}
diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go
index c7dca231..131c3520 100644
--- a/api/pkg/services/message_service.go
+++ b/api/pkg/services/message_service.go
@@ -2,12 +2,14 @@ package services
import (
"context"
+ "encoding/base64"
"fmt"
"strings"
"time"
"github.com/davecgh/go-spew/spew"
"github.com/nyaruka/phonenumbers"
+ "golang.org/x/sync/errgroup"
"github.com/NdoleStudio/httpsms/pkg/events"
"github.com/NdoleStudio/httpsms/pkg/repositories"
@@ -19,14 +21,23 @@ import (
"github.com/NdoleStudio/httpsms/pkg/telemetry"
)
+// ServiceAttachment represents attachment data passed to the service layer
+type ServiceAttachment struct {
+ Name string
+ ContentType string
+ Content string // base64-encoded
+}
+
// MessageService is handles message requests
type MessageService struct {
service
- logger telemetry.Logger
- tracer telemetry.Tracer
- eventDispatcher *EventDispatcher
- phoneService *PhoneService
- repository repositories.MessageRepository
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ eventDispatcher *EventDispatcher
+ phoneService *PhoneService
+ repository repositories.MessageRepository
+ attachmentRepository repositories.AttachmentRepository
+ apiBaseURL string
}
// NewMessageService creates a new MessageService
@@ -36,13 +47,17 @@ func NewMessageService(
repository repositories.MessageRepository,
eventDispatcher *EventDispatcher,
phoneService *PhoneService,
+ attachmentRepository repositories.AttachmentRepository,
+ apiBaseURL string,
) (s *MessageService) {
return &MessageService{
- logger: logger.WithService(fmt.Sprintf("%T", s)),
- tracer: tracer,
- repository: repository,
- phoneService: phoneService,
- eventDispatcher: eventDispatcher,
+ logger: logger.WithService(fmt.Sprintf("%T", s)),
+ tracer: tracer,
+ repository: repository,
+ phoneService: phoneService,
+ eventDispatcher: eventDispatcher,
+ attachmentRepository: attachmentRepository,
+ apiBaseURL: apiBaseURL,
}
}
@@ -289,14 +304,15 @@ func (service *MessageService) StoreEvent(ctx context.Context, message *entities
// MessageReceiveParams parameters registering a message event
type MessageReceiveParams struct {
- Contact string
- UserID entities.UserID
- Owner phonenumbers.PhoneNumber
- Content string
- SIM entities.SIM
- Timestamp time.Time
- Encrypted bool
- Source string
+ Contact string
+ UserID entities.UserID
+ Owner phonenumbers.PhoneNumber
+ Content string
+ SIM entities.SIM
+ Timestamp time.Time
+ Encrypted bool
+ Source string
+ Attachments []ServiceAttachment
}
// ReceiveMessage handles message received by a mobile phone
@@ -306,15 +322,25 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa
ctxLogger := service.tracer.CtxLogger(service.logger, span)
+ messageID := uuid.New()
+
+ ctxLogger.Info(fmt.Sprintf("uploading [%d] attachments for user [%s] message [%s]", len(params.Attachments), params.UserID, messageID))
+ attachmentURLs, err := service.uploadAttachments(ctx, params.UserID, messageID, params.Attachments)
+ if err != nil {
+ msg := fmt.Sprintf("cannot upload attachments for user [%s] message [%s]", params.UserID, messageID)
+ return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
eventPayload := events.MessagePhoneReceivedPayload{
- MessageID: uuid.New(),
- UserID: params.UserID,
- Encrypted: params.Encrypted,
- Owner: phonenumbers.Format(¶ms.Owner, phonenumbers.E164),
- Contact: params.Contact,
- Timestamp: params.Timestamp,
- Content: params.Content,
- SIM: params.SIM,
+ MessageID: messageID,
+ UserID: params.UserID,
+ Encrypted: params.Encrypted,
+ Owner: phonenumbers.Format(¶ms.Owner, phonenumbers.E164),
+ Contact: params.Contact,
+ Timestamp: params.Timestamp,
+ Content: params.Content,
+ SIM: params.SIM,
+ Attachments: attachmentURLs,
}
ctxLogger.Info(fmt.Sprintf("creating cloud event for received with ID [%s]", eventPayload.MessageID))
@@ -331,7 +357,7 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa
msg := fmt.Sprintf("cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())
return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
}
- ctxLogger.Info(fmt.Sprintf("event [%s] dispatched succesfully", event.ID()))
+ ctxLogger.Info(fmt.Sprintf("event [%s] dispatched successfully", event.ID()))
return service.storeReceivedMessage(ctx, eventPayload)
}
@@ -560,6 +586,7 @@ func (service *MessageService) storeReceivedMessage(ctx context.Context, params
UserID: params.UserID,
Contact: params.Contact,
Content: params.Content,
+ Attachments: params.Attachments,
SIM: params.SIM,
Encrypted: params.Encrypted,
Type: entities.MessageTypeMobileOriginated,
@@ -580,6 +607,55 @@ func (service *MessageService) storeReceivedMessage(ctx context.Context, params
return message, nil
}
+func (service *MessageService) uploadAttachments(ctx context.Context, userID entities.UserID, messageID uuid.UUID, attachments []ServiceAttachment) ([]string, error) {
+ if len(attachments) == 0 {
+ return []string{}, nil
+ }
+
+ ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
+ defer span.End()
+
+ g, gCtx := errgroup.WithContext(ctx)
+ urls := make([]string, len(attachments))
+ paths := make([]string, len(attachments))
+
+ for i, attachment := range attachments {
+ i, attachment := i, attachment
+ g.Go(func() error {
+ decoded, err := base64.StdEncoding.DecodeString(attachment.Content)
+ if err != nil {
+ return stacktrace.Propagate(err, fmt.Sprintf("cannot decode base64 content for attachment [%d]", i))
+ }
+
+ sanitizedName := repositories.SanitizeFilename(attachment.Name, i)
+ ext := repositories.ExtensionFromContentType(attachment.ContentType)
+ filename := sanitizedName + ext
+
+ path := fmt.Sprintf("attachments/%s/%s/%d/%s", userID, messageID, i, filename)
+ paths[i] = path
+
+ if err = service.attachmentRepository.Upload(gCtx, path, decoded, attachment.ContentType); err != nil {
+ return stacktrace.Propagate(err, fmt.Sprintf("cannot upload attachment [%d] to path [%s]", i, path))
+ }
+
+ urls[i] = fmt.Sprintf("%s/v1/attachments/%s/%s/%d/%s", service.apiBaseURL, userID, messageID, i, filename)
+ ctxLogger.Info(fmt.Sprintf("uploaded attachment [%d] to [%s]", i, path))
+ return nil
+ })
+ }
+
+ if err := g.Wait(); err != nil {
+ for _, path := range paths {
+ if path != "" {
+ _ = service.attachmentRepository.Delete(ctx, path)
+ }
+ }
+ return nil, stacktrace.Propagate(err, "cannot upload attachments")
+ }
+
+ return urls, nil
+}
+
// HandleMessageParams are parameters for handling a message event
type HandleMessageParams struct {
ID uuid.UUID
diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go
index 9a63886b..ee6cf9b2 100644
--- a/api/pkg/validators/message_handler_validator.go
+++ b/api/pkg/validators/message_handler_validator.go
@@ -2,6 +2,7 @@ package validators
import (
"context"
+ "encoding/base64"
"fmt"
"net/url"
"strings"
@@ -46,6 +47,12 @@ func NewMessageHandlerValidator(
}
}
+const (
+ maxAttachmentCount = 10
+ maxAttachmentSize = (3 * 1024 * 1024) / 2 // 1.5 MB per attachment
+ maxTotalAttachmentSize = 3 * 1024 * 1024 // 3 MB total
+)
+
// ValidateMessageReceive validates the requests.MessageReceive request
func (validator MessageHandlerValidator) ValidateMessageReceive(_ context.Context, request requests.MessageReceive) url.Values {
v := govalidator.New(govalidator.Options{
@@ -58,11 +65,12 @@ func (validator MessageHandlerValidator) ValidateMessageReceive(_ context.Contex
"from": []string{
"required",
},
- "content": []string{
- "required",
- "min:1",
- "max:2048",
- },
+ "content": func() []string {
+ if len(request.Attachments) > 0 {
+ return []string{"max:2048"}
+ }
+ return []string{"required", "min:1", "max:2048"}
+ }(),
"sim": []string{
"required",
"in:" + strings.Join([]string{
@@ -73,7 +81,54 @@ func (validator MessageHandlerValidator) ValidateMessageReceive(_ context.Contex
},
})
- return v.ValidateStruct()
+ errors := v.ValidateStruct()
+
+ if len(request.Attachments) > 0 {
+ attachmentErrors := validator.validateAttachments(request.Attachments)
+ for key, values := range attachmentErrors {
+ for _, value := range values {
+ errors.Add(key, value)
+ }
+ }
+ }
+
+ return errors
+}
+
+func (validator MessageHandlerValidator) validateAttachments(attachments []requests.MessageAttachment) url.Values {
+ errors := url.Values{}
+ allowedTypes := repositories.AllowedContentTypes()
+
+ if len(attachments) > maxAttachmentCount {
+ errors.Add("attachments", fmt.Sprintf("attachment count [%d] exceeds maximum of [%d]", len(attachments), maxAttachmentCount))
+ return errors
+ }
+
+ totalSize := 0
+ for i, attachment := range attachments {
+ if !allowedTypes[attachment.ContentType] {
+ errors.Add("attachments", fmt.Sprintf("attachment [%d] has unsupported content type [%s]", i, attachment.ContentType))
+ continue
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(attachment.Content)
+ if err != nil {
+ errors.Add("attachments", fmt.Sprintf("attachment [%d] has invalid base64 content", i))
+ continue
+ }
+
+ if len(decoded) > maxAttachmentSize {
+ errors.Add("attachments", fmt.Sprintf("attachment [%d] size [%d] exceeds maximum of [%d] bytes", i, len(decoded), maxAttachmentSize))
+ }
+
+ totalSize += len(decoded)
+ }
+
+ if totalSize > maxTotalAttachmentSize {
+ errors.Add("attachments", fmt.Sprintf("total attachment size [%d] exceeds maximum of [%d] bytes", totalSize, maxTotalAttachmentSize))
+ }
+
+ return errors
}
// ValidateMessageSend validates the requests.MessageSend request
diff --git a/docs/superpowers/plans/2026-04-11-mms-attachments.md b/docs/superpowers/plans/2026-04-11-mms-attachments.md
new file mode 100644
index 00000000..a759e1c4
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-11-mms-attachments.md
@@ -0,0 +1,1139 @@
+# MMS Attachment Support Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add MMS attachment upload/download support to the httpSMS API so received MMS attachments are stored in cloud storage and downloadable via a public URL.
+
+**Architecture:** Android sends base64-encoded attachments in the receive request. The API decodes and uploads them to GCS (or in-memory storage) via a storage interface, stores download URLs in the existing `Message.Attachments` field, and exposes an unauthenticated download endpoint. The webhook event payload includes attachment URLs.
+
+**Tech Stack:** Go, Fiber v2, GORM, `cloud.google.com/go/storage`, `errgroup`, `stacktrace`
+
+---
+
+## File Structure
+
+**New files:**
+
+| File | Responsibility |
+| --------------------------------------------------- | -------------------------------------------------------------------------------- |
+| `api/pkg/repositories/attachment_storage.go` | `AttachmentStorage` interface + content-type-to-extension mapping + sanitization |
+| `api/pkg/repositories/gcs_attachment_storage.go` | GCS implementation of `AttachmentStorage` |
+| `api/pkg/repositories/memory_attachment_storage.go` | In-memory implementation of `AttachmentStorage` |
+| `api/pkg/repositories/attachment_storage_test.go` | Unit tests for content-type mapping and filename sanitization |
+| `api/pkg/handlers/attachment_handler.go` | Download endpoint handler (`GET /v1/attachments/...`) |
+
+**Modified files:**
+
+| File | Change |
+| ------------------------------------------------- | ------------------------------------------------------------------------------- |
+| `api/pkg/requests/message_receive_request.go` | Add `Attachments` field + `MessageAttachment` struct |
+| `api/pkg/services/message_service.go` | Add `Attachments` to params, upload logic in `ReceiveMessage()`, set on message |
+| `api/pkg/validators/message_handler_validator.go` | Add attachment count/size/content-type validation |
+| `api/pkg/events/message_phone_received_event.go` | Add `Attachments []string` to payload |
+| `api/pkg/di/container.go` | Wire `AttachmentStorage`, `AttachmentHandler`, `RegisterAttachmentRoutes()` |
+| `api/.env.docker` | Add `GCS_BUCKET_NAME` |
+| `api/go.mod` / `api/go.sum` | Add `cloud.google.com/go/storage` and `golang.org/x/sync` (errgroup) |
+
+---
+
+### Task 1: Add GCS SDK and errgroup dependencies
+
+**Files:**
+
+- Modify: `api/go.mod`
+
+- [ ] **Step 1: Add dependencies**
+
+```bash
+cd api && go get cloud.google.com/go/storage && go get golang.org/x/sync
+```
+
+- [ ] **Step 2: Verify build still works**
+
+Run: `cd api && go build ./...`
+Expected: Build succeeds
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd api && git add go.mod go.sum && git commit -m "chore: add cloud.google.com/go/storage and golang.org/x/sync deps"
+```
+
+---
+
+### Task 2: Storage interface, content-type mapping, and filename sanitization
+
+**Files:**
+
+- Create: `api/pkg/repositories/attachment_storage.go`
+- Create: `api/pkg/repositories/attachment_storage_test.go`
+
+- [ ] **Step 1: Write the test file**
+
+Create `api/pkg/repositories/attachment_storage_test.go`:
+
+```go
+package repositories
+
+import "testing"
+
+func TestExtensionFromContentType(t *testing.T) {
+ tests := []struct {
+ contentType string
+ expected string
+ }{
+ {"image/jpeg", ".jpg"},
+ {"image/png", ".png"},
+ {"image/gif", ".gif"},
+ {"image/webp", ".webp"},
+ {"image/bmp", ".bmp"},
+ {"video/mp4", ".mp4"},
+ {"video/3gpp", ".3gp"},
+ {"audio/mpeg", ".mp3"},
+ {"audio/ogg", ".ogg"},
+ {"audio/amr", ".amr"},
+ {"application/pdf", ".pdf"},
+ {"text/vcard", ".vcf"},
+ {"text/x-vcard", ".vcf"},
+ {"application/octet-stream", ".bin"},
+ {"unknown/type", ".bin"},
+ {"", ".bin"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.contentType, func(t *testing.T) {
+ got := ExtensionFromContentType(tt.contentType)
+ if got != tt.expected {
+ t.Errorf("ExtensionFromContentType(%q) = %q, want %q", tt.contentType, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestSanitizeFilename(t *testing.T) {
+ tests := []struct {
+ name string
+ index int
+ expected string
+ }{
+ {"photo.jpg", 0, "photo"},
+ {"../../etc/passwd", 0, "etcpasswd"},
+ {"hello/world\\test", 0, "helloworldtest"},
+ {"normal_file", 0, "normal_file"},
+ {"", 0, "attachment-0"},
+ {" ", 0, "attachment-0"},
+ {"...", 1, "attachment-1"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := SanitizeFilename(tt.name, tt.index)
+ if got != tt.expected {
+ t.Errorf("SanitizeFilename(%q, %d) = %q, want %q", tt.name, tt.index, got, tt.expected)
+ }
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd api && go test ./pkg/repositories/ -run "TestExtensionFromContentType|TestSanitizeFilename" -v`
+Expected: FAIL — functions not defined
+
+- [ ] **Step 3: Write the storage interface and utility functions**
+
+Create `api/pkg/repositories/attachment_storage.go`:
+
+```go
+package repositories
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "strings"
+)
+
+// AttachmentStorage is the interface for storing and retrieving message attachments
+type AttachmentStorage interface {
+ // Upload stores attachment data at the given path
+ Upload(ctx context.Context, path string, data []byte) error
+ // Download retrieves attachment data from the given path
+ Download(ctx context.Context, path string) ([]byte, error)
+ // Delete removes an attachment at the given path
+ Delete(ctx context.Context, path string) error
+}
+
+// contentTypeExtensions maps MIME types to file extensions
+var contentTypeExtensions = map[string]string{
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+ "image/bmp": ".bmp",
+ "video/mp4": ".mp4",
+ "video/3gpp": ".3gp",
+ "audio/mpeg": ".mp3",
+ "audio/ogg": ".ogg",
+ "audio/amr": ".amr",
+ "application/pdf": ".pdf",
+ "text/vcard": ".vcf",
+ "text/x-vcard": ".vcf",
+}
+
+// AllowedContentTypes returns the set of allowed MIME types for attachments
+func AllowedContentTypes() map[string]bool {
+ allowed := make(map[string]bool, len(contentTypeExtensions))
+ for ct := range contentTypeExtensions {
+ allowed[ct] = true
+ }
+ return allowed
+}
+
+// ExtensionFromContentType returns the file extension for a MIME content type.
+// Returns ".bin" if the content type is not recognized.
+func ExtensionFromContentType(contentType string) string {
+ if ext, ok := contentTypeExtensions[contentType]; ok {
+ return ext
+ }
+ return ".bin"
+}
+
+// ContentTypeFromExtension returns the MIME content type for a file extension.
+// Returns "application/octet-stream" if the extension is not recognized.
+func ContentTypeFromExtension(ext string) string {
+ for ct, e := range contentTypeExtensions {
+ if e == ext {
+ return ct
+ }
+ }
+ return "application/octet-stream"
+}
+
+// SanitizeFilename removes path separators and traversal sequences from a filename.
+// Returns "attachment-{index}" if the sanitized name is empty.
+func SanitizeFilename(name string, index int) string {
+ name = strings.TrimSuffix(name, filepath.Ext(name))
+ name = strings.ReplaceAll(name, "/", "")
+ name = strings.ReplaceAll(name, "\\", "")
+ name = strings.ReplaceAll(name, "..", "")
+ name = strings.TrimSpace(name)
+
+ if name == "" {
+ return fmt.Sprintf("attachment-%d", index)
+ }
+ return name
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd api && go test ./pkg/repositories/ -run "TestExtensionFromContentType|TestSanitizeFilename" -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd api && git add -A && git commit -m "feat: add AttachmentStorage interface and content-type utilities"
+```
+
+---
+
+### Task 3: Memory storage implementation
+
+**Files:**
+
+- Create: `api/pkg/repositories/memory_attachment_storage.go`
+
+- [ ] **Step 1: Write the implementation**
+
+Create `api/pkg/repositories/memory_attachment_storage.go`:
+
+```go
+package repositories
+
+import (
+ "context"
+ "fmt"
+ "sync"
+
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/palantir/stacktrace"
+)
+
+// MemoryAttachmentStorage stores attachments in memory
+type MemoryAttachmentStorage struct {
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ data sync.Map
+}
+
+// NewMemoryAttachmentStorage creates a new MemoryAttachmentStorage
+func NewMemoryAttachmentStorage(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+) *MemoryAttachmentStorage {
+ return &MemoryAttachmentStorage{
+ logger: logger.WithService(fmt.Sprintf("%T", &MemoryAttachmentStorage{})),
+ tracer: tracer,
+ }
+}
+
+// Upload stores attachment data at the given path
+func (s *MemoryAttachmentStorage) Upload(ctx context.Context, path string, data []byte) error {
+ _, span := s.tracer.Start(ctx)
+ defer span.End()
+
+ s.data.Store(path, data)
+ s.logger.Info(fmt.Sprintf("stored attachment at path [%s] with size [%d]", path, len(data)))
+ return nil
+}
+
+// Download retrieves attachment data from the given path
+func (s *MemoryAttachmentStorage) Download(ctx context.Context, path string) ([]byte, error) {
+ _, span := s.tracer.Start(ctx)
+ defer span.End()
+
+ value, ok := s.data.Load(path)
+ if !ok {
+ return nil, stacktrace.NewError(fmt.Sprintf("attachment not found at path [%s]", path))
+ }
+ return value.([]byte), nil
+}
+
+// Delete removes an attachment at the given path
+func (s *MemoryAttachmentStorage) Delete(ctx context.Context, path string) error {
+ _, span := s.tracer.Start(ctx)
+ defer span.End()
+
+ s.data.Delete(path)
+ s.logger.Info(fmt.Sprintf("deleted attachment at path [%s]", path))
+ return nil
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `cd api && go build ./...`
+Expected: Build succeeds
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd api && git add -A && git commit -m "feat: add MemoryAttachmentStorage implementation"
+```
+
+---
+
+### Task 4: GCS storage implementation
+
+**Files:**
+
+- Create: `api/pkg/repositories/gcs_attachment_storage.go`
+
+- [ ] **Step 1: Write the implementation**
+
+Create `api/pkg/repositories/gcs_attachment_storage.go`:
+
+```go
+package repositories
+
+import (
+ "context"
+ "fmt"
+ "io"
+
+ "cloud.google.com/go/storage"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/palantir/stacktrace"
+)
+
+// GCSAttachmentStorage stores attachments in Google Cloud Storage
+type GCSAttachmentStorage struct {
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ client *storage.Client
+ bucket string
+}
+
+// NewGCSAttachmentStorage creates a new GCSAttachmentStorage
+func NewGCSAttachmentStorage(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ client *storage.Client,
+ bucket string,
+) *GCSAttachmentStorage {
+ return &GCSAttachmentStorage{
+ logger: logger.WithService(fmt.Sprintf("%T", &GCSAttachmentStorage{})),
+ tracer: tracer,
+ client: client,
+ bucket: bucket,
+ }
+}
+
+// Upload stores attachment data at the given path in GCS
+func (s *GCSAttachmentStorage) Upload(ctx context.Context, path string, data []byte) error {
+ ctx, span := s.tracer.Start(ctx)
+ defer span.End()
+
+ writer := s.client.Bucket(s.bucket).Object(path).NewWriter(ctx)
+ if _, err := writer.Write(data); err != nil {
+ return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot write attachment to GCS path [%s]", path)))
+ }
+
+ if err := writer.Close(); err != nil {
+ return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot close GCS writer for path [%s]", path)))
+ }
+
+ s.logger.Info(fmt.Sprintf("uploaded attachment to GCS path [%s/%s] with size [%d]", s.bucket, path, len(data)))
+ return nil
+}
+
+// Download retrieves attachment data from the given path in GCS
+func (s *GCSAttachmentStorage) Download(ctx context.Context, path string) ([]byte, error) {
+ ctx, span := s.tracer.Start(ctx)
+ defer span.End()
+
+ reader, err := s.client.Bucket(s.bucket).Object(path).NewReader(ctx)
+ if err != nil {
+ return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot open GCS reader for path [%s]", path)))
+ }
+ defer reader.Close()
+
+ data, err := io.ReadAll(reader)
+ if err != nil {
+ return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot read attachment from GCS path [%s]", path)))
+ }
+
+ return data, nil
+}
+
+// Delete removes an attachment at the given path in GCS
+func (s *GCSAttachmentStorage) Delete(ctx context.Context, path string) error {
+ ctx, span := s.tracer.Start(ctx)
+ defer span.End()
+
+ if err := s.client.Bucket(s.bucket).Object(path).Delete(ctx); err != nil {
+ return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot delete GCS object at path [%s]", path)))
+ }
+
+ s.logger.Info(fmt.Sprintf("deleted attachment from GCS path [%s/%s]", s.bucket, path))
+ return nil
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `cd api && go build ./...`
+Expected: Build succeeds
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd api && git add -A && git commit -m "feat: add GCSAttachmentStorage implementation"
+```
+
+---
+
+### Task 5: Update request and event structs
+
+**Files:**
+
+- Modify: `api/pkg/requests/message_receive_request.go` (full file)
+- Modify: `api/pkg/services/message_service.go:290-300` (MessageReceiveParams)
+- Modify: `api/pkg/events/message_phone_received_event.go:14-23` (payload struct)
+
+**Important:** The `requests` package already imports `services` (for `ToMessageReceiveParams`), so we **cannot** import `requests` from `services`. Define a `ServiceAttachment` struct in the services package to avoid a circular import.
+
+- [ ] **Step 1: Add ServiceAttachment to services package**
+
+In `api/pkg/services/message_service.go`, add after the imports (before `MessageService` struct at line 22):
+
+```go
+// ServiceAttachment represents attachment data passed to the service layer
+type ServiceAttachment struct {
+ Name string
+ ContentType string
+ Content string // base64-encoded
+}
+```
+
+Update `MessageReceiveParams` (lines 290-300) to add `Attachments`:
+
+```go
+type MessageReceiveParams struct {
+ Contact string
+ UserID entities.UserID
+ Owner phonenumbers.PhoneNumber
+ Content string
+ SIM entities.SIM
+ Timestamp time.Time
+ Encrypted bool
+ Source string
+ Attachments []ServiceAttachment
+}
+```
+
+- [ ] **Step 2: Add MessageAttachment struct and update MessageReceive request**
+
+In `api/pkg/requests/message_receive_request.go`, add the `MessageAttachment` struct before the `MessageReceive` struct, and add the `Attachments` field:
+
+```go
+// MessageAttachment represents a single MMS attachment in a receive request
+type MessageAttachment struct {
+ // Name is the original filename of the attachment
+ Name string `json:"name" example:"photo.jpg"`
+ // ContentType is the MIME type of the attachment
+ ContentType string `json:"content_type" example:"image/jpeg"`
+ // Content is the base64-encoded attachment data
+ Content string `json:"content" example:"base64data..."`
+}
+
+// MessageReceive is the payload for receiving an SMS/MMS message
+type MessageReceive 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 received on a phone"`
+ // Encrypted is used to determine if the content is end-to-end encrypted
+ Encrypted bool `json:"encrypted" example:"false"`
+ // SIM card that received the message
+ SIM entities.SIM `json:"sim" example:"SIM1"`
+ // Timestamp is the time when the event was emitted
+ Timestamp time.Time `json:"timestamp" example:"2022-06-05T14:26:09.527976+03:00"`
+ // Attachments is the list of MMS attachments received with the message
+ Attachments []MessageAttachment `json:"attachments"`
+}
+```
+
+Update `ToMessageReceiveParams` to convert attachments:
+
+```go
+func (input *MessageReceive) ToMessageReceiveParams(userID entities.UserID, source string) *services.MessageReceiveParams {
+ phone, _ := phonenumbers.Parse(input.To, phonenumbers.UNKNOWN_REGION)
+
+ attachments := make([]services.ServiceAttachment, len(input.Attachments))
+ for i, a := range input.Attachments {
+ attachments[i] = services.ServiceAttachment{
+ Name: a.Name,
+ ContentType: a.ContentType,
+ Content: a.Content,
+ }
+ }
+
+ return &services.MessageReceiveParams{
+ Source: source,
+ Contact: input.From,
+ UserID: userID,
+ Timestamp: input.Timestamp,
+ Encrypted: input.Encrypted,
+ Owner: *phone,
+ Content: input.Content,
+ SIM: input.SIM,
+ Attachments: attachments,
+ }
+}
+```
+
+- [ ] **Step 3: Update MessagePhoneReceivedPayload**
+
+In `api/pkg/events/message_phone_received_event.go`, add `Attachments` field to the payload struct (after the `SIM` field):
+
+```go
+type MessagePhoneReceivedPayload struct {
+ MessageID uuid.UUID `json:"message_id"`
+ UserID entities.UserID `json:"user_id"`
+ Owner string `json:"owner"`
+ Encrypted bool `json:"encrypted"`
+ Contact string `json:"contact"`
+ Timestamp time.Time `json:"timestamp"`
+ Content string `json:"content"`
+ SIM entities.SIM `json:"sim"`
+ Attachments []string `json:"attachments"`
+}
+```
+
+- [ ] **Step 4: Verify build compiles (will fail until service constructor is updated)**
+
+Run: `cd api && go vet ./pkg/requests/... ./pkg/events/...`
+Expected: No errors in these packages
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd api && git add -A && git commit -m "feat: add attachment fields to request, params, and event structs"
+```
+
+---
+
+### Task 6: Add attachment validation
+
+**Files:**
+
+- Modify: `api/pkg/validators/message_handler_validator.go:49-77`
+
+- [ ] **Step 1: Update ValidateMessageReceive to validate attachments**
+
+In `api/pkg/validators/message_handler_validator.go`, replace the `ValidateMessageReceive` method (lines 49-77) with:
+
+```go
+const (
+ maxAttachmentCount = 10
+ maxAttachmentSize = (3 * 1024 * 1024) / 2 // 1.5 MB
+)
+
+// ValidateMessageReceive validates the requests.MessageReceive request
+func (validator MessageHandlerValidator) ValidateMessageReceive(_ context.Context, request requests.MessageReceive) url.Values {
+ v := govalidator.New(govalidator.Options{
+ Data: &request,
+ Rules: govalidator.MapData{
+ "to": []string{
+ "required",
+ phoneNumberRule,
+ },
+ "from": []string{
+ "required",
+ },
+ "content": []string{
+ "required",
+ "min:1",
+ "max:2048",
+ },
+ "sim": []string{
+ "required",
+ "in:" + strings.Join([]string{
+ string(entities.SIM1),
+ string(entities.SIM2),
+ }, ","),
+ },
+ },
+ })
+
+ errors := v.ValidateStruct()
+
+ if len(request.Attachments) > 0 {
+ attachmentErrors := validator.validateAttachments(request.Attachments)
+ for key, values := range attachmentErrors {
+ for _, value := range values {
+ errors.Add(key, value)
+ }
+ }
+ }
+
+ return errors
+}
+
+func (validator MessageHandlerValidator) validateAttachments(attachments []requests.MessageAttachment) url.Values {
+ errors := url.Values{}
+ allowedTypes := repositories.AllowedContentTypes()
+
+ if len(attachments) > maxAttachmentCount {
+ errors.Add("attachments", fmt.Sprintf("attachment count [%d] exceeds maximum of [%d]", len(attachments), maxAttachmentCount))
+ return errors
+ }
+
+ for i, attachment := range attachments {
+ if !allowedTypes[attachment.ContentType] {
+ errors.Add("attachments", fmt.Sprintf("attachment [%d] has unsupported content type [%s]", i, attachment.ContentType))
+ continue
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(attachment.Content)
+ if err != nil {
+ errors.Add("attachments", fmt.Sprintf("attachment [%d] has invalid base64 content", i))
+ continue
+ }
+
+ if len(decoded) > maxAttachmentSize {
+ errors.Add("attachments", fmt.Sprintf("attachment [%d] size [%d] exceeds maximum of [%d] bytes", i, len(decoded), maxAttachmentSize))
+ }
+ }
+
+ return errors
+}
+```
+
+Add these imports to the file: `"encoding/base64"`, `"github.com/NdoleStudio/httpsms/pkg/repositories"`.
+
+- [ ] **Step 2: Verify build**
+
+Run: `cd api && go vet ./pkg/validators/...`
+Expected: No errors
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd api && git add -A && git commit -m "feat: add attachment count, size, and content-type validation"
+```
+
+---
+
+### Task 7: Upload logic in MessageService.ReceiveMessage()
+
+**Files:**
+
+- Modify: `api/pkg/services/message_service.go:22-47` (struct + constructor)
+- Modify: `api/pkg/services/message_service.go:302-337` (ReceiveMessage)
+- Modify: `api/pkg/services/message_service.go:550-581` (storeReceivedMessage)
+
+- [ ] **Step 1: Add AttachmentStorage and apiBaseURL to MessageService**
+
+Update the `MessageService` struct (lines 22-30):
+
+```go
+type MessageService struct {
+ service
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ eventDispatcher *EventDispatcher
+ phoneService *PhoneService
+ repository repositories.MessageRepository
+ attachmentStorage repositories.AttachmentStorage
+ apiBaseURL string
+}
+```
+
+Update `NewMessageService` (lines 33-47) to accept the new parameters:
+
+```go
+func NewMessageService(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ repository repositories.MessageRepository,
+ eventDispatcher *EventDispatcher,
+ phoneService *PhoneService,
+ attachmentStorage repositories.AttachmentStorage,
+ apiBaseURL string,
+) (s *MessageService) {
+ return &MessageService{
+ logger: logger.WithService(fmt.Sprintf("%T", s)),
+ tracer: tracer,
+ repository: repository,
+ phoneService: phoneService,
+ eventDispatcher: eventDispatcher,
+ attachmentStorage: attachmentStorage,
+ apiBaseURL: apiBaseURL,
+ }
+}
+```
+
+- [ ] **Step 2: Add the uploadAttachments helper method**
+
+Add this after `storeReceivedMessage`. Add imports: `"encoding/base64"`, `"golang.org/x/sync/errgroup"`:
+
+```go
+func (service *MessageService) uploadAttachments(ctx context.Context, userID entities.UserID, messageID uuid.UUID, attachments []ServiceAttachment) ([]string, error) {
+ ctx, span := service.tracer.Start(ctx)
+ defer span.End()
+
+ ctxLogger := service.tracer.CtxLogger(service.logger, span)
+
+ g, gCtx := errgroup.WithContext(ctx)
+ urls := make([]string, len(attachments))
+ paths := make([]string, len(attachments))
+
+ for i, attachment := range attachments {
+ i, attachment := i, attachment
+ g.Go(func() error {
+ decoded, err := base64.StdEncoding.DecodeString(attachment.Content)
+ if err != nil {
+ return stacktrace.Propagate(err, fmt.Sprintf("cannot decode base64 content for attachment [%d]", i))
+ }
+
+ sanitizedName := repositories.SanitizeFilename(attachment.Name, i)
+ ext := repositories.ExtensionFromContentType(attachment.ContentType)
+ filename := sanitizedName + ext
+
+ path := fmt.Sprintf("attachments/%s/%s/%d/%s", userID, messageID, i, filename)
+ paths[i] = path
+
+ if err = service.attachmentStorage.Upload(gCtx, path, decoded); err != nil {
+ return stacktrace.Propagate(err, fmt.Sprintf("cannot upload attachment [%d] to path [%s]", i, path))
+ }
+
+ urls[i] = fmt.Sprintf("%s/v1/attachments/%s/%s/%d/%s", service.apiBaseURL, userID, messageID, i, filename)
+ ctxLogger.Info(fmt.Sprintf("uploaded attachment [%d] to [%s]", i, path))
+ return nil
+ })
+ }
+
+ if err := g.Wait(); err != nil {
+ for _, path := range paths {
+ if path != "" {
+ _ = service.attachmentStorage.Delete(ctx, path)
+ }
+ }
+ return nil, stacktrace.Propagate(err, "cannot upload attachments")
+ }
+
+ return urls, nil
+}
+```
+
+- [ ] **Step 3: Update ReceiveMessage to upload attachments before event dispatch**
+
+Replace the `ReceiveMessage` method (lines 302-337):
+
+```go
+func (service *MessageService) ReceiveMessage(ctx context.Context, params *MessageReceiveParams) (*entities.Message, error) {
+ ctx, span := service.tracer.Start(ctx)
+ defer span.End()
+
+ ctxLogger := service.tracer.CtxLogger(service.logger, span)
+
+ messageID := uuid.New()
+ var attachmentURLs []string
+
+ if len(params.Attachments) > 0 {
+ ctxLogger.Info(fmt.Sprintf("uploading [%d] attachments for message [%s]", len(params.Attachments), messageID))
+ var err error
+ attachmentURLs, err = service.uploadAttachments(ctx, params.UserID, messageID, params.Attachments)
+ if err != nil {
+ msg := fmt.Sprintf("cannot upload attachments for message [%s]", messageID)
+ return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+ }
+
+ eventPayload := events.MessagePhoneReceivedPayload{
+ MessageID: messageID,
+ UserID: params.UserID,
+ Encrypted: params.Encrypted,
+ Owner: phonenumbers.Format(¶ms.Owner, phonenumbers.E164),
+ Contact: params.Contact,
+ Timestamp: params.Timestamp,
+ Content: params.Content,
+ SIM: params.SIM,
+ Attachments: attachmentURLs,
+ }
+
+ ctxLogger.Info(fmt.Sprintf("creating cloud event for received with ID [%s]", eventPayload.MessageID))
+
+ event, err := service.createMessagePhoneReceivedEvent(params.Source, eventPayload)
+ if err != nil {
+ msg := fmt.Sprintf("cannot create %T from payload with message id [%s]", event, eventPayload.MessageID)
+ return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ ctxLogger.Info(fmt.Sprintf("created event [%s] with id [%s] and message id [%s]", event.Type(), event.ID(), eventPayload.MessageID))
+
+ if err = service.eventDispatcher.Dispatch(ctx, event); err != nil {
+ msg := fmt.Sprintf("cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())
+ return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+ ctxLogger.Info(fmt.Sprintf("event [%s] dispatched successfully", event.ID()))
+
+ return service.storeReceivedMessage(ctx, eventPayload)
+}
+```
+
+- [ ] **Step 4: Update storeReceivedMessage to set Attachments on message**
+
+In the `storeReceivedMessage` method (lines 550-581), add `Attachments` to the message construction:
+
+```go
+ message := &entities.Message{
+ ID: params.MessageID,
+ Owner: params.Owner,
+ UserID: params.UserID,
+ Contact: params.Contact,
+ Content: params.Content,
+ Attachments: params.Attachments,
+ SIM: params.SIM,
+ Encrypted: params.Encrypted,
+ Type: entities.MessageTypeMobileOriginated,
+ Status: entities.MessageStatusReceived,
+ RequestReceivedAt: params.Timestamp,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ OrderTimestamp: params.Timestamp,
+ ReceivedAt: ¶ms.Timestamp,
+ }
+```
+
+- [ ] **Step 5: Verify the services package compiles**
+
+Run: `cd api && go vet ./pkg/services/...`
+Expected: No errors (the full build may still fail until DI container is updated)
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd api && git add -A && git commit -m "feat: add attachment upload logic to MessageService.ReceiveMessage()"
+```
+
+---
+
+### Task 8: Attachment download handler
+
+**Files:**
+
+- Create: `api/pkg/handlers/attachment_handler.go`
+
+- [ ] **Step 1: Write the handler**
+
+Create `api/pkg/handlers/attachment_handler.go`:
+
+```go
+package handlers
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/NdoleStudio/httpsms/pkg/repositories"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/gofiber/fiber/v2"
+ "github.com/palantir/stacktrace"
+)
+
+// AttachmentHandler handles attachment download requests
+type AttachmentHandler struct {
+ handler
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ storage repositories.AttachmentStorage
+}
+
+// NewAttachmentHandler creates a new AttachmentHandler
+func NewAttachmentHandler(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ storage repositories.AttachmentStorage,
+) (h *AttachmentHandler) {
+ return &AttachmentHandler{
+ logger: logger.WithService(fmt.Sprintf("%T", h)),
+ tracer: tracer,
+ storage: storage,
+ }
+}
+
+// RegisterRoutes registers the routes for the AttachmentHandler (no auth middleware — public endpoint)
+func (h *AttachmentHandler) RegisterRoutes(router fiber.Router) {
+ router.Get("/v1/attachments/:userID/:messageID/:attachmentIndex/:filename", h.GetAttachment)
+}
+
+// GetAttachment downloads an attachment
+// @Summary Download a message attachment
+// @Description Download an MMS attachment by its path components
+// @Tags Attachments
+// @Produce octet-stream
+// @Param userID path string true "User ID"
+// @Param messageID path string true "Message ID"
+// @Param attachmentIndex path string true "Attachment index"
+// @Param filename path string true "Filename with extension"
+// @Success 200 {file} binary
+// @Failure 404 {object} responses.NotFoundResponse
+// @Failure 500 {object} responses.InternalServerError
+// @Router /attachments/{userID}/{messageID}/{attachmentIndex}/{filename} [get]
+func (h *AttachmentHandler) GetAttachment(c *fiber.Ctx) error {
+ ctx, span := h.tracer.StartFromFiberCtx(c)
+ defer span.End()
+
+ ctxLogger := h.tracer.CtxLogger(h.logger, span)
+
+ userID := c.Params("userID")
+ messageID := c.Params("messageID")
+ attachmentIndex := c.Params("attachmentIndex")
+ filename := c.Params("filename")
+
+ path := fmt.Sprintf("attachments/%s/%s/%s/%s", userID, messageID, attachmentIndex, filename)
+
+ ctxLogger.Info(fmt.Sprintf("downloading attachment from path [%s]", path))
+
+ data, err := h.storage.Download(ctx, path)
+ if err != nil {
+ msg := fmt.Sprintf("cannot download attachment from path [%s]", path)
+ ctxLogger.Warn(stacktrace.Propagate(err, msg))
+ return h.responseNotFound(c, "attachment not found")
+ }
+
+ ext := filepath.Ext(filename)
+ contentType := repositories.ContentTypeFromExtension(ext)
+
+ c.Set("Content-Type", contentType)
+ c.Set("Content-Disposition", "attachment")
+ c.Set("X-Content-Type-Options", "nosniff")
+
+ return c.Send(data)
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `cd api && go vet ./pkg/handlers/...`
+Expected: No errors
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd api && git add -A && git commit -m "feat: add AttachmentHandler for downloading attachments"
+```
+
+---
+
+### Task 9: Wire everything in the DI container and env config
+
+**Files:**
+
+- Modify: `api/pkg/di/container.go:104-163` (NewContainer)
+- Modify: `api/pkg/di/container.go:1424-1434` (MessageService creation)
+- Modify: `api/.env.docker`
+
+- [ ] **Step 1: Add GCS_BUCKET_NAME to .env.docker**
+
+In `api/.env.docker`, add after the `REDIS_URL=redis://@redis:6379` line (line 49):
+
+```env
+
+# Google Cloud Storage bucket for MMS attachments. Leave empty to use in-memory storage.
+GCS_BUCKET_NAME=
+```
+
+- [ ] **Step 2: Add `attachmentStorage` field to Container struct**
+
+In `api/pkg/di/container.go`, add `attachmentStorage` to the `Container` struct (around line 82-90):
+
+```go
+type Container struct {
+ projectID string
+ db *gorm.DB
+ dedicatedDB *gorm.DB
+ version string
+ app *fiber.App
+ eventDispatcher *services.EventDispatcher
+ logger telemetry.Logger
+ attachmentStorage repositories.AttachmentStorage
+}
+```
+
+- [ ] **Step 3: Add AttachmentStorage, APIBaseURL, and AttachmentHandler getters to container.go**
+
+Add these methods to `api/pkg/di/container.go`. Also add required imports: `"cloud.google.com/go/storage"` and `"context"`:
+
+```go
+// AttachmentStorage creates a cached AttachmentStorage based on configuration
+func (container *Container) AttachmentStorage() repositories.AttachmentStorage {
+ if container.attachmentStorage != nil {
+ return container.attachmentStorage
+ }
+
+ bucket := os.Getenv("GCS_BUCKET_NAME")
+ if bucket != "" {
+ container.logger.Debug("creating GCSAttachmentStorage")
+ client, err := storage.NewClient(context.Background())
+ if err != nil {
+ container.logger.Fatal(stacktrace.Propagate(err, "cannot create GCS client"))
+ }
+ container.attachmentStorage = repositories.NewGCSAttachmentStorage(
+ container.Logger(),
+ container.Tracer(),
+ client,
+ bucket,
+ )
+ } else {
+ container.logger.Debug("creating MemoryAttachmentStorage (GCS_BUCKET_NAME not set)")
+ container.attachmentStorage = repositories.NewMemoryAttachmentStorage(
+ container.Logger(),
+ container.Tracer(),
+ )
+ }
+
+ return container.attachmentStorage
+}
+
+// APIBaseURL returns the API base URL derived from EVENTS_QUEUE_ENDPOINT
+func (container *Container) APIBaseURL() string {
+ endpoint := os.Getenv("EVENTS_QUEUE_ENDPOINT")
+ return strings.TrimSuffix(endpoint, "/v1/events")
+}
+
+// AttachmentHandler creates a new AttachmentHandler
+func (container *Container) AttachmentHandler() (handler *handlers.AttachmentHandler) {
+ container.logger.Debug(fmt.Sprintf("creating %T", handler))
+ return handlers.NewAttachmentHandler(
+ container.Logger(),
+ container.Tracer(),
+ container.AttachmentStorage(),
+ )
+}
+
+// RegisterAttachmentRoutes registers routes for the /attachments prefix
+func (container *Container) RegisterAttachmentRoutes() {
+ container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.AttachmentHandler{}))
+ container.AttachmentHandler().RegisterRoutes(container.App())
+}
+```
+
+- [ ] **Step 3: Update MessageService creation to pass new parameters**
+
+Update the `MessageService()` getter (around line 1424-1434):
+
+```go
+func (container *Container) MessageService() (service *services.MessageService) {
+ container.logger.Debug(fmt.Sprintf("creating %T", service))
+ return services.NewMessageService(
+ container.Logger(),
+ container.Tracer(),
+ container.MessageRepository(),
+ container.EventDispatcher(),
+ container.PhoneService(),
+ container.AttachmentStorage(),
+ container.APIBaseURL(),
+ )
+}
+```
+
+- [ ] **Step 4: Register attachment routes in NewContainer**
+
+In the `NewContainer` function (lines 104-163), add `container.RegisterAttachmentRoutes()` after `container.RegisterMessageRoutes()` (after line 120):
+
+```go
+ container.RegisterMessageRoutes()
+ container.RegisterAttachmentRoutes()
+ container.RegisterBulkMessageRoutes()
+```
+
+- [ ] **Step 5: Verify full build**
+
+Run: `cd api && go build ./...`
+Expected: Build succeeds — all components are now wired
+
+- [ ] **Step 6: Run all tests**
+
+Run: `cd api && go test ./...`
+Expected: All tests pass
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd api && git add -A && git commit -m "feat: wire attachment storage and handler in DI container
+
+- Add AttachmentStorage selection (GCS vs memory) based on GCS_BUCKET_NAME env var
+- Wire AttachmentHandler for public download endpoint
+- Pass storage and API base URL to MessageService
+- Add GCS_BUCKET_NAME to .env.docker"
+```
+
+---
+
+### Task 10: Final verification
+
+- [ ] **Step 1: Run full build**
+
+Run: `cd api && go build -o ./tmp/main.exe .`
+Expected: Build succeeds
+
+- [ ] **Step 2: Run all tests**
+
+Run: `cd api && go test ./... -v`
+Expected: All tests pass including `TestExtensionFromContentType` and `TestSanitizeFilename`
+
+- [ ] **Step 3: Verify go vet**
+
+Run: `cd api && go vet ./...`
+Expected: No issues
+
+- [ ] **Step 4: Final commit if any remaining changes**
+
+```bash
+cd api && git add -A && git diff --cached --stat
+```
diff --git a/docs/superpowers/specs/2026-04-11-mms-attachments-design.md b/docs/superpowers/specs/2026-04-11-mms-attachments-design.md
new file mode 100644
index 00000000..7beea85f
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-11-mms-attachments-design.md
@@ -0,0 +1,220 @@
+# MMS Attachment Support — Design Spec
+
+## Problem
+
+The Android app now forwards MMS attachments (as base64-encoded data) when receiving MMS messages via `HttpSmsApiService.receive()`. The API server needs to:
+
+1. Accept attachment data in the receive endpoint
+2. Upload attachments to cloud storage (GCS or in-memory)
+3. Store download URLs in the Message entity
+4. Serve a download endpoint for retrieving attachments
+5. Include attachment URLs in webhook event payloads
+
+## Approach
+
+**Approach A: Storage Interface + Minimal New Code** — Add an `AttachmentStorage` interface with GCS and memory implementations. Upload logic lives in the existing `MessageService.ReceiveMessage()` flow (synchronous). A new `AttachmentHandler` serves downloads. No new database tables — content type is encoded in the URL file extension.
+
+## Design
+
+### 1. Storage Interface
+
+**New file: `pkg/repositories/attachment_storage.go`**
+
+```go
+type AttachmentStorage interface {
+ Upload(ctx context.Context, path string, data []byte) error
+ Download(ctx context.Context, path string) ([]byte, error)
+ Delete(ctx context.Context, path string) error
+}
+```
+
+**GCS Implementation** (`pkg/repositories/gcs_attachment_storage.go`):
+
+- Uses `cloud.google.com/go/storage` SDK
+- Configured with bucket name from `GCS_BUCKET_NAME` env var
+- Stores objects at: `attachments/{userID}/{messageID}/{index}/{name}.{ext}`
+- Extension derived from content type (e.g., `image/jpeg` → `.jpg`); falls back to `.bin` only when no mapping exists
+
+**Memory Implementation** (`pkg/repositories/memory_attachment_storage.go`):
+
+- `sync.Map`-backed in-memory store
+- Used when `GCS_BUCKET_NAME` is empty/unset (local dev, testing)
+
+**DI selection** (in `container.go`):
+
+```go
+if os.Getenv("GCS_BUCKET_NAME") != "" {
+ return NewGCSAttachmentStorage(bucket, tracer, logger)
+}
+return NewMemoryAttachmentStorage(tracer, logger)
+```
+
+### 2. Environment Variables
+
+| Variable | Description | Default |
+| ----------------- | ------------------------------------------------------- | ---------------------------------- |
+| `GCS_BUCKET_NAME` | GCS bucket for attachments. Empty = use memory storage. | `httpsms-86c51.appspot.com` (prod) |
+
+The API base URL for constructing download links is derived from `EVENTS_QUEUE_ENDPOINT` by stripping the `/v1/events` suffix.
+
+### 3. Request & Validation Changes
+
+**Updated `MessageReceive` request** (`pkg/requests/`):
+
+```go
+type MessageReceive struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ Content string `json:"content"`
+ Encrypted bool `json:"encrypted"`
+ SIM entities.SIM `json:"sim"`
+ Timestamp time.Time `json:"timestamp"`
+ Attachments []MessageAttachment `json:"attachments"` // NEW
+}
+
+type MessageAttachment struct {
+ Name string `json:"name"`
+ ContentType string `json:"content_type"`
+ Content string `json:"content"` // base64-encoded
+}
+```
+
+**Updated `MessageReceiveParams`** (`pkg/services/`):
+The `ToMessageReceiveParams()` method must propagate attachments to the service layer:
+
+```go
+type MessageReceiveParams struct {
+ // ... existing fields ...
+ Attachments []requests.MessageAttachment // NEW — raw attachment data for upload
+}
+```
+
+**Filename sanitization:**
+The `Name` field from the Android client must be sanitized to prevent path traversal attacks. Strip all path separators (`/`, `\`), directory traversal sequences (`..`), and non-printable characters. If the sanitized name is empty, use a fallback like `attachment-{index}`.
+
+**Content type allowlist:**
+Only allow known-safe MIME types from the extension mapping table (Section 5). Reject attachments with unrecognized content types with a 400 error.
+
+**Validation rules** (in `pkg/validators/`):
+
+- Attachment count must be ≤ 10
+- Each decoded attachment must be ≤ 1.5 MB (1,572,864 bytes)
+- Content type must be in the allowlist
+- If any limit is exceeded → **reject entire request with 400 Bad Request**
+- Validation happens before any upload or storage
+
+### 4. Upload Flow (Synchronous in Receive)
+
+In `MessageService.ReceiveMessage()`:
+
+1. Validate attachment count, sizes, and content types
+2. Upload attachments **in parallel** using `errgroup`:
+ a. Decode base64 content
+ b. Sanitize `name` (strip path separators, `..`, non-printable chars; fallback to `attachment-{index}`)
+ c. Map `content_type` → file extension (e.g., `image/jpeg` → `.jpg`, unknown → `.bin`)
+ d. Upload to storage at path: `attachments/{userID}/{messageID}/{index}/{sanitizedName}.{ext}`
+ e. Build download URL: `{apiBaseURL}/v1/attachments/{userID}/{messageID}/{index}/{sanitizedName}.{ext}`
+3. If any upload fails → best-effort delete of already-uploaded files, then return 500
+4. Collect download URLs into `message.Attachments` (existing `pq.StringArray` field)
+5. Set `Attachments` on `MessagePhoneReceivedPayload` before dispatching event
+6. `storeReceivedMessage()` copies `payload.Attachments` → `message.Attachments`
+7. Store message in database
+8. Fire `message.phone.received` event (includes attachment URLs)
+
+### 5. Content Type → Extension Mapping
+
+A utility function maps MIME types to file extensions:
+
+| Content Type | Extension |
+| ----------------- | --------- |
+| `image/jpeg` | `.jpg` |
+| `image/png` | `.png` |
+| `image/gif` | `.gif` |
+| `image/webp` | `.webp` |
+| `image/bmp` | `.bmp` |
+| `video/mp4` | `.mp4` |
+| `video/3gpp` | `.3gp` |
+| `audio/mpeg` | `.mp3` |
+| `audio/ogg` | `.ogg` |
+| `audio/amr` | `.amr` |
+| `application/pdf` | `.pdf` |
+| `text/vcard` | `.vcf` |
+| `text/x-vcard` | `.vcf` |
+| _(default)_ | `.bin` |
+
+This covers common MMS content types. New mappings can be added as needed.
+
+### 6. Download Handler
+
+**New file: `pkg/handlers/attachment_handler.go`**
+
+**Route:** `GET /v1/attachments/:userID/:messageID/:attachmentIndex/:filename`
+
+- Registered **without authentication middleware** — publicly accessible, consistent with outgoing attachment URLs
+- The `{userID}/{messageID}/{attachmentIndex}` path components provide sufficient obscurity (UUIDs are unguessable)
+
+**Download flow:**
+
+1. Parse URL params (userID, messageID, attachmentIndex, filename)
+2. Construct storage path: `attachments/{userID}/{messageID}/{attachmentIndex}/{filename}`
+3. Fetch bytes from `AttachmentStorage.Download(ctx, path)`
+4. Derive `Content-Type` from filename extension
+5. Set security headers: `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`
+6. Respond with binary data + correct `Content-Type` header
+7. Return 404 if attachment not found in storage
+
+### 7. Webhook Event Changes
+
+**Updated `MessagePhoneReceivedPayload`** (`pkg/events/message_phone_received_event.go`):
+
+```go
+type MessagePhoneReceivedPayload struct {
+ MessageID uuid.UUID `json:"message_id"`
+ UserID entities.UserID `json:"user_id"`
+ Owner string `json:"owner"`
+ Encrypted bool `json:"encrypted"`
+ Contact string `json:"contact"`
+ Timestamp time.Time `json:"timestamp"`
+ Content string `json:"content"`
+ SIM entities.SIM `json:"sim"`
+ Attachments []string `json:"attachments"` // NEW — download URLs
+}
+```
+
+Webhook subscribers will receive the array of download URLs. They can `GET` each URL directly — no authentication required.
+
+### 8. Files Changed / Created
+
+**New files:**
+
+- `pkg/repositories/attachment_storage.go` — Interface definition
+- `pkg/repositories/gcs_attachment_storage.go` — GCS implementation
+- `pkg/repositories/memory_attachment_storage.go` — Memory implementation
+- `pkg/handlers/attachment_handler.go` — Download endpoint handler
+- `pkg/validators/attachment_handler_validator.go` — Download param validation
+
+**Modified files:**
+
+- `pkg/requests/message_receive.go` (or wherever `MessageReceive` is defined) — Add `Attachments` field
+- `pkg/validators/message_handler_validator.go` — Add attachment count/size validation
+- `pkg/services/message_service.go` — Add upload logic to `ReceiveMessage()`
+- `pkg/events/message_phone_received_event.go` — Add `Attachments` field to payload
+- `pkg/di/container.go` — Wire storage, new handler, pass storage to message service
+- `api/.env.docker` — Add `GCS_BUCKET_NAME` variable
+- `go.mod` / `go.sum` — Add `cloud.google.com/go/storage` dependency
+
+### 9. Validation Constraints
+
+| Constraint | Value | Behavior |
+| ------------------------------- | ------------------------ | ---------------------------------------------------- |
+| Max attachment count | 10 | 400 Bad Request |
+| Max attachment size (decoded) | 1.5 MB (1,572,864 bytes) | 400 Bad Request |
+| Content type not in allowlist | — | 400 Bad Request |
+| Missing/empty attachments array | — | Message stored without attachments (normal SMS flow) |
+
+### 10. Error Handling
+
+- Storage upload failure → Best-effort delete of already-uploaded attachments, then return 500; message is NOT stored
+- Storage download failure → Return 404 or 500 depending on error type
+- Invalid base64 content → Return 400 Bad Request
+- All errors wrapped with `stacktrace.Propagate()` per project convention
diff --git a/web/.env.docker b/web/.env.docker
index d48c328f..b1751dfb 100644
--- a/web/.env.docker
+++ b/web/.env.docker
@@ -15,3 +15,7 @@ FIREBASE_STORAGE_BUCKET=httpsms-docker.appspot.com
FIREBASE_MESSAGING_SENDER_ID=668063041624
FIREBASE_APP_ID=668063041624:web:29b9e3b7027965ba08a22d
FIREBASE_MEASUREMENT_ID=G-18VRYL22PZ
+
+# Cloudflare Turnstile site key for captcha on the search messages page
+# Get your site key at https://developers.cloudflare.com/turnstile/get-started/
+CLOUDFLARE_TURNSTILE_SITE_KEY=
diff --git a/web/package.json b/web/package.json
index 61cf0770..9eb11649 100644
--- a/web/package.json
+++ b/web/package.json
@@ -65,7 +65,7 @@
"@nuxtjs/vuetify": "^1.12.3",
"@types/qrcode": "^1.5.6",
"@vue/test-utils": "^1.3.6",
- "axios": "^0.30.3",
+ "axios": "^0.31.0",
"babel-core": "7.0.0-bridge.0",
"babel-jest": "^30.2.0",
"eslint": "^8.57.1",
diff --git a/web/pages/threads/_id/index.vue b/web/pages/threads/_id/index.vue
index 81931b9a..fc0e27f3 100644
--- a/web/pages/threads/_id/index.vue
+++ b/web/pages/threads/_id/index.vue
@@ -162,6 +162,7 @@
:color="isMT(message) ? 'primary' : 'default'"
>
@@ -186,7 +187,7 @@
{{
mdiPaperclip
}}
- {{ attachment }}
+ {{ formatAttachmentName(attachment) }}
@@ -460,6 +461,14 @@ export default Vue.extend({
},
methods: {
+ formatAttachmentName(url: string): string {
+ const parts = url.split('/')
+ if (parts.length >= 2) {
+ return '/' + parts.slice(-2).join('/')
+ }
+ return url
+ },
+
isPending(message: Message): boolean {
return ['sending', 'pending', 'scheduled'].includes(message.status)
},
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 7c170173..17babf5d 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -119,10 +119,10 @@ importers:
version: 12.1.0(eslint@8.57.1)(typescript@4.9.5)
'@nuxtjs/eslint-module':
specifier: ^4.1.0
- version: 4.1.0(eslint@8.57.1)(rollup@3.29.5)(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)
+ version: 4.1.0(eslint@8.57.1)(rollup@3.30.0)(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)
'@nuxtjs/stylelint-module':
specifier: ^5.2.0
- version: 5.2.0(postcss@8.5.6)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)
+ version: 5.2.0(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)
'@nuxtjs/vuetify':
specifier: ^1.12.3
version: 1.12.3(vue@2.7.16)(webpack@5.104.1)
@@ -133,8 +133,8 @@ importers:
specifier: ^1.3.6
version: 1.3.6(vue-template-compiler@2.7.16)(vue@2.7.16)
axios:
- specifier: ^0.30.3
- version: 0.30.3
+ specifier: ^0.31.0
+ version: 0.31.0
babel-core:
specifier: 7.0.0-bridge.0
version: 7.0.0-bridge.0(@babel/core@7.28.4)
@@ -3009,8 +3009,8 @@ packages:
resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==}
engines: {node: '>= 0.4'}
- axios@0.30.3:
- resolution: {integrity: sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==}
+ axios@0.31.0:
+ resolution: {integrity: sha512-HGIUj/P74co3rSLBV9SHz9LMgCmrXFEtkfMcC5r6bS5j3dBHUcAje2tS4fmU6WM20kuhvUX04XE58594dpgi1g==}
babel-code-frame@6.26.0:
resolution: {integrity: sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g==}
@@ -3151,6 +3151,9 @@ packages:
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+ brace-expansion@2.1.0:
+ resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
+
braces@2.3.2:
resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==}
engines: {node: '>=0.10.0'}
@@ -4676,8 +4679,8 @@ packages:
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
- filelist@1.0.4:
- resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
+ filelist@1.0.6:
+ resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
fill-range@4.0.0:
resolution: {integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==}
@@ -4745,8 +4748,8 @@ packages:
flush-write-stream@1.1.1:
resolution: {integrity: sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==}
- follow-redirects@1.15.11:
- resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
+ follow-redirects@1.16.0:
+ resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@@ -4925,6 +4928,7 @@ packages:
git-raw-commits@4.0.0:
resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==}
engines: {node: '>=16'}
+ deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.
hasBin: true
git-up@7.0.0:
@@ -6252,6 +6256,10 @@ packages:
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
engines: {node: '>=10'}
+ minimatch@5.1.9:
+ resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
+ engines: {node: '>=10'}
+
minimatch@9.0.1:
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -7351,6 +7359,10 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
+ postcss@8.5.10:
+ resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -7694,8 +7706,8 @@ packages:
engines: {node: '>=10.0.0'}
hasBin: true
- rollup@3.29.5:
- resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==}
+ rollup@3.30.0:
+ resolution: {integrity: sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
@@ -11783,9 +11795,9 @@ snapshots:
node-html-parser: 6.1.13
ufo: 1.6.1
- '@nuxt/kit@3.12.2(rollup@3.29.5)':
+ '@nuxt/kit@3.12.2(rollup@3.30.0)':
dependencies:
- '@nuxt/schema': 3.12.2(rollup@3.29.5)
+ '@nuxt/schema': 3.12.2(rollup@3.30.0)
c12: 1.11.1
consola: 3.2.3
defu: 6.1.4
@@ -11803,16 +11815,16 @@ snapshots:
semver: 7.7.3
ufo: 1.6.1
unctx: 2.3.1
- unimport: 3.7.2(rollup@3.29.5)
+ unimport: 3.7.2(rollup@3.30.0)
untyped: 1.4.2
transitivePeerDependencies:
- magicast
- rollup
- supports-color
- '@nuxt/kit@3.7.4(rollup@3.29.5)':
+ '@nuxt/kit@3.7.4(rollup@3.30.0)':
dependencies:
- '@nuxt/schema': 3.7.4(rollup@3.29.5)
+ '@nuxt/schema': 3.7.4(rollup@3.30.0)
c12: 1.4.2
consola: 3.2.3
defu: 6.1.4
@@ -11828,7 +11840,7 @@ snapshots:
semver: 7.7.3
ufo: 1.6.1
unctx: 2.3.1
- unimport: 3.4.0(rollup@3.29.5)
+ unimport: 3.4.0(rollup@3.30.0)
untyped: 1.4.0
transitivePeerDependencies:
- rollup
@@ -11850,7 +11862,7 @@ snapshots:
consola: 3.2.3
node-fetch-native: 1.6.7
- '@nuxt/schema@3.12.2(rollup@3.29.5)':
+ '@nuxt/schema@3.12.2(rollup@3.30.0)':
dependencies:
compatx: 0.1.8
consola: 3.2.3
@@ -11862,13 +11874,13 @@ snapshots:
std-env: 3.7.0
ufo: 1.6.1
uncrypto: 0.1.3
- unimport: 3.7.2(rollup@3.29.5)
+ unimport: 3.7.2(rollup@3.30.0)
untyped: 1.4.2
transitivePeerDependencies:
- rollup
- supports-color
- '@nuxt/schema@3.7.4(rollup@3.29.5)':
+ '@nuxt/schema@3.7.4(rollup@3.30.0)':
dependencies:
'@nuxt/ui-templates': 1.3.1
consola: 3.2.3
@@ -11879,7 +11891,7 @@ snapshots:
postcss-import-resolver: 2.0.0
std-env: 3.7.0
ufo: 1.6.1
- unimport: 3.7.2(rollup@3.29.5)
+ unimport: 3.7.2(rollup@3.30.0)
untyped: 1.4.2
transitivePeerDependencies:
- rollup
@@ -12155,9 +12167,9 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- '@nuxtjs/eslint-module@4.1.0(eslint@8.57.1)(rollup@3.29.5)(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)':
+ '@nuxtjs/eslint-module@4.1.0(eslint@8.57.1)(rollup@3.30.0)(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)':
dependencies:
- '@nuxt/kit': 3.7.4(rollup@3.29.5)
+ '@nuxt/kit': 3.7.4(rollup@3.30.0)
chokidar: 3.5.3
eslint: 8.57.1
eslint-webpack-plugin: 4.0.1(eslint@8.57.1)(webpack@5.104.1)
@@ -12193,14 +12205,14 @@ snapshots:
minimatch: 3.1.2
sitemap: 4.1.1
- '@nuxtjs/stylelint-module@5.2.0(postcss@8.5.6)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)':
+ '@nuxtjs/stylelint-module@5.2.0(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)':
dependencies:
- '@nuxt/kit': 3.12.2(rollup@3.29.5)
+ '@nuxt/kit': 3.12.2(rollup@3.30.0)
chokidar: 3.6.0
pathe: 1.1.2
stylelint: 15.11.0(typescript@4.9.5)
stylelint-webpack-plugin: 5.0.1(stylelint@15.11.0(typescript@4.9.5))(webpack@5.104.1)
- vite-plugin-stylelint: 5.3.1(postcss@8.5.6)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))
+ vite-plugin-stylelint: 5.3.1(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))
transitivePeerDependencies:
- '@types/stylelint'
- magicast
@@ -12272,21 +12284,21 @@ snapshots:
estree-walker: 2.0.2
picomatch: 2.3.1
- '@rollup/pluginutils@5.0.4(rollup@3.29.5)':
+ '@rollup/pluginutils@5.0.4(rollup@3.30.0)':
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
picomatch: 2.3.1
optionalDependencies:
- rollup: 3.29.5
+ rollup: 3.30.0
- '@rollup/pluginutils@5.1.0(rollup@3.29.5)':
+ '@rollup/pluginutils@5.1.0(rollup@3.30.0)':
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
picomatch: 2.3.1
optionalDependencies:
- rollup: 3.29.5
+ rollup: 3.30.0
'@sinclair/typebox@0.27.8': {}
@@ -13251,9 +13263,9 @@ snapshots:
available-typed-arrays@1.0.5: {}
- axios@0.30.3:
+ axios@0.31.0:
dependencies:
- follow-redirects: 1.15.11
+ follow-redirects: 1.16.0
form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
@@ -13474,6 +13486,11 @@ snapshots:
dependencies:
balanced-match: 1.0.2
+ brace-expansion@2.1.0:
+ dependencies:
+ balanced-match: 1.0.2
+ optional: true
+
braces@2.3.2:
dependencies:
arr-flatten: 1.1.0
@@ -15196,9 +15213,9 @@ snapshots:
file-uri-to-path@1.0.0: {}
- filelist@1.0.4:
+ filelist@1.0.6:
dependencies:
- minimatch: 5.1.6
+ minimatch: 5.1.9
optional: true
fill-range@4.0.0:
@@ -15332,7 +15349,7 @@ snapshots:
inherits: 2.0.4
readable-stream: 2.3.8
- follow-redirects@1.15.11: {}
+ follow-redirects@1.16.0: {}
for-each@0.3.3:
dependencies:
@@ -16260,7 +16277,7 @@ snapshots:
jake@10.9.4:
dependencies:
async: 3.2.6
- filelist: 1.0.4
+ filelist: 1.0.6
picocolors: 1.1.1
optional: true
@@ -17178,6 +17195,11 @@ snapshots:
dependencies:
brace-expansion: 2.0.1
+ minimatch@5.1.9:
+ dependencies:
+ brace-expansion: 2.1.0
+ optional: true
+
minimatch@9.0.1:
dependencies:
brace-expansion: 2.0.1
@@ -17558,7 +17580,7 @@ snapshots:
dependencies:
call-bind: 1.0.2
define-properties: 1.2.1
- has-symbols: 1.1.0
+ has-symbols: 1.0.3
object-keys: 1.1.1
object.fromentries@2.0.7:
@@ -18425,6 +18447,12 @@ snapshots:
picocolors: 1.0.0
source-map-js: 1.0.2
+ postcss@8.5.10:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -18821,7 +18849,7 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
- rollup@3.29.5:
+ rollup@3.30.0:
optionalDependencies:
fsevents: 2.3.3
@@ -18845,7 +18873,7 @@ snapshots:
dependencies:
call-bind: 1.0.2
get-intrinsic: 1.3.0
- has-symbols: 1.1.0
+ has-symbols: 1.0.3
isarray: 2.0.5
safe-buffer@5.1.2: {}
@@ -19768,7 +19796,7 @@ snapshots:
dependencies:
call-bind: 1.0.2
has-bigints: 1.0.2
- has-symbols: 1.1.0
+ has-symbols: 1.0.3
which-boxed-primitive: 1.0.2
uncrypto@0.1.3: {}
@@ -19801,9 +19829,9 @@ snapshots:
unicorn-magic@0.1.0: {}
- unimport@3.4.0(rollup@3.29.5):
+ unimport@3.4.0(rollup@3.30.0):
dependencies:
- '@rollup/pluginutils': 5.0.4(rollup@3.29.5)
+ '@rollup/pluginutils': 5.0.4(rollup@3.30.0)
escape-string-regexp: 5.0.0
fast-glob: 3.3.1
local-pkg: 0.4.3
@@ -19817,9 +19845,9 @@ snapshots:
transitivePeerDependencies:
- rollup
- unimport@3.7.2(rollup@3.29.5):
+ unimport@3.7.2(rollup@3.30.0):
dependencies:
- '@rollup/pluginutils': 5.1.0(rollup@3.29.5)
+ '@rollup/pluginutils': 5.1.0(rollup@3.30.0)
acorn: 8.15.0
escape-string-regexp: 5.0.0
estree-walker: 3.0.3
@@ -20004,24 +20032,24 @@ snapshots:
rollup: 2.79.2
vite: 4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1)
- vite-plugin-stylelint@5.3.1(postcss@8.5.6)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1)):
+ vite-plugin-stylelint@5.3.1(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1)):
dependencies:
- '@rollup/pluginutils': 5.1.0(rollup@3.29.5)
+ '@rollup/pluginutils': 5.1.0(rollup@3.30.0)
chokidar: 3.6.0
debug: 4.4.1
stylelint: 15.11.0(typescript@4.9.5)
vite: 4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1)
optionalDependencies:
postcss: 8.5.6
- rollup: 3.29.5
+ rollup: 3.30.0
transitivePeerDependencies:
- supports-color
vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1):
dependencies:
esbuild: 0.18.20
- postcss: 8.5.6
- rollup: 3.29.5
+ postcss: 8.5.10
+ rollup: 3.30.0
optionalDependencies:
'@types/node': 25.1.0
fsevents: 2.3.3
@@ -20413,7 +20441,7 @@ snapshots:
available-typed-arrays: 1.0.5
call-bind: 1.0.2
for-each: 0.3.3
- gopd: 1.2.0
+ gopd: 1.0.1
has-tostringtag: 1.0.2
which@1.3.1: