diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 26d01123..27bcd737 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,7 +22,7 @@ jobs:
- name: Checkout 🛎
uses: actions/checkout@master
- - uses: pnpm/action-setup@v4
+ - uses: pnpm/action-setup@v5
name: Install pnpm
with:
version: 9
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 970afcbf..3c361930 100644
--- a/api/go.mod
+++ b/api/go.mod
@@ -3,11 +3,12 @@ module github.com/NdoleStudio/httpsms
go 1.25.0
require (
- cloud.google.com/go/cloudtasks v1.13.7
+ 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
- github.com/NdoleStudio/go-otelroundtripper v0.0.13
+ github.com/NdoleStudio/go-otelroundtripper v0.0.14
github.com/NdoleStudio/lemonsqueezy-go v1.3.1
github.com/NdoleStudio/plunk-go v0.0.2
github.com/avast/retry-go/v5 v5.0.0
@@ -30,27 +31,28 @@ require (
github.com/joho/godotenv v1.5.1
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/jszwec/csvutil v1.10.0
- github.com/lib/pq v1.11.2
- github.com/nyaruka/phonenumbers v1.6.10
+ github.com/lib/pq v1.12.2
+ github.com/nyaruka/phonenumbers v1.7.1
github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkg/errors v0.9.1
github.com/pusher/pusher-http-go/v5 v5.1.1
github.com/redis/go-redis/extra/redisotel/v9 v9.18.0
github.com/redis/go-redis/v9 v9.18.0
- github.com/rs/zerolog v1.34.0
+ github.com/rs/zerolog v1.35.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/swag v1.16.6
github.com/thedevsaddam/govalidator v1.9.10
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc
- github.com/uptrace/uptrace-go v1.40.0
+ github.com/uptrace/uptrace-go v1.41.1
github.com/xuri/excelize/v2 v2.10.1
- go.opentelemetry.io/otel v1.40.0
- go.opentelemetry.io/otel/metric v1.40.0
- go.opentelemetry.io/otel/sdk v1.40.0
- go.opentelemetry.io/otel/sdk/metric v1.40.0
- go.opentelemetry.io/otel/trace v1.40.0
- google.golang.org/api v0.269.0
+ go.opentelemetry.io/otel v1.43.0
+ go.opentelemetry.io/otel/metric v1.43.0
+ 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
gorm.io/driver/sqlite v1.6.0
@@ -64,90 +66,90 @@ require (
github.com/inbucket/html2text v1.0.0 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect
- github.com/olekukonko/ll v0.1.6 // indirect
+ github.com/olekukonko/ll v0.1.8 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/cast v1.10.0 // indirect
- github.com/yuin/goldmark v1.7.16 // indirect
+ github.com/yuin/goldmark v1.8.2 // indirect
)
require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect
- cloud.google.com/go/auth v0.18.2 // indirect
+ cloud.google.com/go/auth v0.19.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/firestore v1.21.0 // indirect
- cloud.google.com/go/iam v1.5.3 // indirect
- cloud.google.com/go/longrunning v0.8.0 // indirect
- cloud.google.com/go/monitoring v1.24.3 // indirect
- cloud.google.com/go/storage v1.60.0 // indirect
- cloud.google.com/go/trace v1.11.7 // indirect
+ 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/trace v1.12.0 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/ClickHouse/ch-go v0.71.0 // indirect
- github.com/ClickHouse/clickhouse-go/v2 v2.43.0 // indirect
+ github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
- github.com/PuerkitoBio/goquery v1.11.0 // indirect
- github.com/andybalholm/brotli v1.2.0 // indirect
+ github.com/PuerkitoBio/goquery v1.12.0 // indirect
+ github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/clipperhouse/displaywidth v0.10.0 // indirect
+ github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
- github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
- github.com/fatih/color v1.18.0 // indirect
+ github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
+ github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
- github.com/go-jose/go-jose/v4 v4.1.3 // indirect
+ github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-openapi/jsonpointer v0.22.4 // indirect
- github.com/go-openapi/jsonreference v0.21.4 // indirect
- github.com/go-openapi/spec v0.22.3 // indirect
- github.com/go-openapi/swag/conv v0.25.4 // indirect
- github.com/go-openapi/swag/jsonname v0.25.4 // indirect
- github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
- github.com/go-openapi/swag/loading v0.25.4 // indirect
- github.com/go-openapi/swag/stringutils v0.25.4 // indirect
- github.com/go-openapi/swag/typeutils v0.25.4 // indirect
- github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
+ github.com/go-openapi/jsonpointer v0.22.5 // indirect
+ github.com/go-openapi/jsonreference v0.21.5 // indirect
+ github.com/go-openapi/spec v0.22.4 // indirect
+ github.com/go-openapi/swag/conv v0.25.5 // indirect
+ github.com/go-openapi/swag/jsonname v0.25.5 // indirect
+ github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
+ github.com/go-openapi/swag/loading v0.25.5 // indirect
+ github.com/go-openapi/swag/stringutils v0.25.5 // indirect
+ github.com/go-openapi/swag/typeutils v0.25.5 // indirect
+ github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
+ github.com/goccy/go-json v0.10.6 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
- github.com/googleapis/gax-go/v2 v2.17.0 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
+ github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
- github.com/hashicorp/go-version v1.8.0 // indirect
+ github.com/hashicorp/go-version v1.9.0 // indirect
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.8.0 // 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
- github.com/klauspost/compress v1.18.4 // indirect
+ github.com/klauspost/compress v1.18.5 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-runewidth v0.0.20 // indirect
- github.com/mattn/go-sqlite3 v1.14.34 // indirect
+ github.com/mattn/go-runewidth v0.0.22 // indirect
+ github.com/mattn/go-sqlite3 v1.14.39 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
- github.com/olekukonko/tablewriter v1.1.3 // indirect
- github.com/paulmach/orb v0.12.0 // indirect
- github.com/pierrec/lz4/v4 v4.1.25 // indirect
+ github.com/olekukonko/tablewriter v1.1.4 // indirect
+ github.com/paulmach/orb v0.13.0 // indirect
+ github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.18.0 // indirect
@@ -162,43 +164,42 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/vanng822/css v1.0.1 // indirect
- github.com/vanng822/go-premailer v1.31.0 // indirect
+ github.com/vanng822/go-premailer v1.33.0 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/contrib v1.40.0 // indirect
- go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0 // indirect
- go.opentelemetry.io/contrib/processors/minsev v0.13.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
- go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect
- go.opentelemetry.io/otel/log v0.16.0 // indirect
- go.opentelemetry.io/otel/sdk/log v0.16.0 // indirect
- go.opentelemetry.io/proto/otlp v1.9.0 // indirect
+ go.opentelemetry.io/contrib v1.42.0 // indirect
+ go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0 // indirect
+ go.opentelemetry.io/contrib/processors/minsev v0.15.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
+ go.opentelemetry.io/otel/log v0.19.0 // indirect
+ go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/crypto v0.48.0 // indirect
- golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
- golang.org/x/mod v0.33.0 // indirect
- golang.org/x/net v0.50.0 // indirect
- golang.org/x/oauth2 v0.35.0 // indirect
- golang.org/x/sync v0.19.0 // indirect
- golang.org/x/sys v0.41.0 // indirect
- golang.org/x/text v0.34.0 // indirect
- golang.org/x/time v0.14.0 // indirect
- golang.org/x/tools v0.42.0 // indirect
+ golang.org/x/crypto v0.49.0 // indirect
+ golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
+ 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/sys v0.42.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ golang.org/x/tools v0.43.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
- google.golang.org/genproto v0.0.0-20260217200457-a2cb2272a1e9 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
- google.golang.org/grpc v1.79.3 // indirect
+ google.golang.org/genproto v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/grpc v1.80.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.6.0 // indirect
diff --git a/api/go.sum b/api/go.sum
index a5e74ec7..60df8733 100644
--- a/api/go.sum
+++ b/api/go.sum
@@ -4,28 +4,28 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
-cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
-cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
+cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ=
+cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
-cloud.google.com/go/cloudtasks v1.13.7 h1:H2v8GEolNtMFfYzUpZBaZbydqU7drpyo99GtAgA+m4I=
-cloud.google.com/go/cloudtasks v1.13.7/go.mod h1:H0TThOUG+Ml34e2+ZtW6k6nt4i9KuH3nYAJ5mxh7OM4=
+cloud.google.com/go/cloudtasks v1.14.0 h1:l+9VVqB6Bbpn1NhYBwn9TMs5Yu7jU0bSfd9mrRilt48=
+cloud.google.com/go/cloudtasks v1.14.0/go.mod h1:mFzsLKuM4gzzmlbu1363510Fjm5ZJR+8mH1C2w5roJo=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
-cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
-cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
+cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U=
+cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY=
cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
-cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
-cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
-cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
-cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
-cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8=
-cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0=
-cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
-cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
+cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY=
+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.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=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
@@ -34,8 +34,8 @@ firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVA
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
-github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE=
-github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
+github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
+github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
@@ -54,16 +54,16 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
-github.com/NdoleStudio/go-otelroundtripper v0.0.13 h1:fDgdxcNJov4LTrMhXqJnF/E3jO4HJVczj90wkxh5PSc=
-github.com/NdoleStudio/go-otelroundtripper v0.0.13/go.mod h1:UIUQ22ErFoBUyLuPDrVNRRKmBHBTfzQO9GF1ztqDvqo=
+github.com/NdoleStudio/go-otelroundtripper v0.0.14 h1:t/VoW2772wTDQnjdECxxWbtZtbnpJyuRSKxRC/hHfTg=
+github.com/NdoleStudio/go-otelroundtripper v0.0.14/go.mod h1:ObQjHo1D/daXeESbFIi0UXJN0yJu4zQ7mMeSKvm4a1I=
github.com/NdoleStudio/lemonsqueezy-go v1.3.1 h1:lMUVgdAx2onbOUJIVPR05xAANYuCMXBRaGWpAdA4LiM=
github.com/NdoleStudio/lemonsqueezy-go v1.3.1/go.mod h1:xKRsRX1jSI6mLrVXyWh2sF/1isxTioZrSjWy6HpA3xQ=
github.com/NdoleStudio/plunk-go v0.0.2 h1:afPW7MHK4Z3rsybpJBnmTmxKCLKF1M7sPI+BNGPf35A=
github.com/NdoleStudio/plunk-go v0.0.2/go.mod h1:pqG3zKhpn/A2bL1K+WsWzvfTpOeSkYgXhNk5H65uEc8=
-github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
-github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
-github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
-github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
+github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
+github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
+github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
@@ -80,8 +80,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
-github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM=
@@ -92,7 +92,6 @@ github.com/cockroachdb/cockroach-go/v2 v2.4.3 h1:LJO3K3jC5WXvMePRQSJE1NsIGoFGcEx
github.com/cockroachdb/cockroach-go/v2 v2.4.3/go.mod h1:9U179XbCx4qFWtNhc7BiWLPfuyMVQ7qdAhfrwLz1vH0=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
-github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -111,10 +110,10 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNf
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
-github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
-github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
-github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
-github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
+github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
+github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -125,43 +124,44 @@ github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AY
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-hermes/hermes/v2 v2.6.2 h1:RuGQlICVtIHixfxtYwN7hAoqGyGxr+D3kE42oE6emcw=
github.com/go-hermes/hermes/v2 v2.6.2/go.mod h1:RLVNk31/1KqF35vK3mAaQVuJvMH+K5//6OTGJk+j/80=
-github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
-github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
-github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
-github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
-github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
-github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
-github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
+github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
+github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
+github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
+github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
+github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
+github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
-github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
-github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
-github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
-github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
-github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
-github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
-github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
-github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
-github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
-github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
-github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
-github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
-github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
-github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
-github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
-github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
-github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
-github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
-github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
-github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
+github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
+github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
+github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
+github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
+github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
+github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
+github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
+github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
+github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
+github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
+github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
+github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
+github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
+github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
+github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
+github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
+github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofiber/contrib/otelfiber v1.0.10 h1:Bu28Pi4pfYmGfIc/9+sNaBbFwTHGY/zpSIK5jBxuRtM=
github.com/gofiber/contrib/otelfiber v1.0.10/go.mod h1:jN6AvS1HolDHTQHFURsV+7jSX96FpXYeKH6nmkq8AIw=
github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
@@ -170,15 +170,12 @@ github.com/gofiber/swagger v1.1.1 h1:FZVhVQQ9s1ZKLHL/O0loLh49bYB5l1HEAgxDlcTtkRA
github.com/gofiber/swagger v1.1.1/go.mod h1:vtvY/sQAMc/lGTUCg0lqmBL7Ht9O7uzChpbvJeJQINw=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -190,10 +187,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
-github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
-github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
-github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
+github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
+github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
+github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
+github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
@@ -204,8 +201,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
-github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
-github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
+github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hirosassa/zerodriver v0.1.4 h1:8bzamKUOHHq03aEk12qi/lnji2dM+IhFOe+RpKpIZFM=
github.com/hirosassa/zerodriver v0.1.4/go.mod h1:hHOOAQvVGwBV1iVVYujM6vwOBBqQcBIFpJxCD9mJU7Y=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
@@ -216,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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
-github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
+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=
@@ -234,33 +231,24 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI=
github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
-github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
-github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
+github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
-github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/lib/pq v1.12.2 h1:ajJNv84limnK3aPbDIhLtcjrUbqAw/5XNdkuI6KNe/Q=
+github.com/lib/pq v1.12.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
-github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
-github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
-github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk=
+github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mattn/go-sqlite3 v1.14.39 h1:sIwSjlJGOaRJjw44/HXaeTblZMjseqr6OOio1tz/+JI=
+github.com/mattn/go-sqlite3 v1.14.39/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -270,26 +258,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
-github.com/nyaruka/phonenumbers v1.6.10 h1:kGTxTzd320dUamRB/MPeZSIwKNLn4vHlysOt5Cp8uoU=
-github.com/nyaruka/phonenumbers v1.6.10/go.mod h1:IUu45lj2bSeYXQuxDyyuzOrdV10tyRa1YSsfH8EKN5c=
+github.com/nyaruka/phonenumbers v1.7.1 h1:k8FHBMLegwW2tEIhsurC5YJk5Dix++H1k6liu1LUruY=
+github.com/nyaruka/phonenumbers v1.7.1/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
-github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
-github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
-github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
-github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
+github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
+github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
+github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
+github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177 h1:nRlQD0u1871kaznCnn1EvYiMbum36v7hw1DLPEjds4o=
github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177/go.mod h1:ao5zGxj8Z4x60IOVYZUbDSmt3R8Ddo080vEgPosHpak=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
-github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
-github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
-github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
-github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
-github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
+github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
+github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
+github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
+github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
@@ -311,9 +297,8 @@ github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
-github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
-github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
-github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
+github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
+github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
@@ -328,7 +313,6 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cma
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@@ -338,24 +322,20 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOjzTeQRiMCvU=
github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90=
-github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0=
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
-github.com/uptrace/uptrace-go v1.40.0 h1:fMva36FZ/eujU60hq+ke9HdYGkXP5jJXUTNeEuWDI+I=
-github.com/uptrace/uptrace-go v1.40.0/go.mod h1:HJhggr8UMkJ+keR8B9o4KsF7kxT8lKH7Ra8X2DRwqdc=
+github.com/uptrace/uptrace-go v1.41.1 h1:EtWkkdOQqtuJMZyzeU0zT5VH6ppVY12yOouQK3VRccw=
+github.com/uptrace/uptrace-go v1.41.1/go.mod h1:gdn1eRLG3KCtTyiw+L8tG+tb/wnpiyIfLfTH2qh/5Mw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
-github.com/vanng822/go-premailer v1.31.0 h1:r1a1WH2I5NnGMhrmjVZyYhY0ThvaamKBkS2UuM91Fuo=
-github.com/vanng822/go-premailer v1.31.0/go.mod h1:hzI26/YvzUADrxqifxGLJvNvn3tWBU6VMHRvxsskpuo=
-github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
-github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
-github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
+github.com/vanng822/go-premailer v1.33.0 h1:nglIpKn/7e3kIAwYByiH5xpauFur7RwAucqyZ59hcic=
+github.com/vanng822/go-premailer v1.33.0/go.mod h1:LGYI7ym6FQ7KcHN16LiQRF+tlan7qwhP1KEhpTINFpo=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
@@ -364,63 +344,59 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
-github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
-github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
-go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib v1.40.0 h1:Vv1qG9EIHpJWl2EFxOlhv0WgGNYQD9s0U/z3xkEonl8=
-go.opentelemetry.io/contrib v1.40.0/go.mod h1:8z64gUE9jZgMGFCiGyF7NZnN5N0xaVaxdnV2DXBmTkE=
-go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA=
-go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
-go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0 h1:n8qdwrebNEHF/zHpueuZ4OacdJ8CdSaP7xef9WRZXTQ=
-go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0/go.mod h1:Z1pjGxUL3nJ/IbDDfL6rBD0Xbz7ZOViRqrIUg4l1CYE=
-go.opentelemetry.io/contrib/processors/minsev v0.13.0 h1:pADh6ro5deXRfNmry136khTZYWVXn9NKZR5nZuEXtXw=
-go.opentelemetry.io/contrib/processors/minsev v0.13.0/go.mod h1:MC0s+ldbPprTztVZQ/pecYqPSxwfjQkdnCeC3u6uGQU=
+go.opentelemetry.io/contrib v1.42.0 h1:845qj52z2T/bLInfZmG8AdbTO7delSd6eGVVHcAikzw=
+go.opentelemetry.io/contrib v1.42.0/go.mod h1:JYdNU7Pl/2ckKMGp8/G7zeyhEbtRmy9Q8bcrtv75Znk=
+go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
+go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
+go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0 h1:fM78cKITJ2r08cl+nw5i+hI9zWAu3iak8o1Os/ca2Ck=
+go.opentelemetry.io/contrib/instrumentation/runtime v0.67.0/go.mod h1:ybmlzIqGcQzwt5lAfi8TpSnHo/CI3yv1Czodmm+OJa8=
+go.opentelemetry.io/contrib/processors/minsev v0.15.0 h1:82auGK0+tBbWa3Zy8RoLegy6OL1OULFk50W4eO2rSXE=
+go.opentelemetry.io/contrib/processors/minsev v0.15.0/go.mod h1:+mJGjwRqiPNYDU1hehhHeO6On5DBqSX8JXOqBnawT20=
go.opentelemetry.io/contrib/propagators/b3 v1.19.0 h1:ulz44cpm6V5oAeg5Aw9HyqGFMS6XM7untlMEhD7YzzA=
go.opentelemetry.io/contrib/propagators/b3 v1.19.0/go.mod h1:OzCmE2IVS+asTI+odXQstRGVfXQ4bXv9nMBRK0nNyqQ=
-go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
-go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
-go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
-go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
-go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
-go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
-go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
-go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
+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.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=
+go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/oteltest v1.0.0-RC3 h1:MjaeegZTaX0Bv9uB9CrdVjOFM/8slRjReoWoV9xDCpY=
go.opentelemetry.io/otel/oteltest v1.0.0-RC3/go.mod h1:xpzajI9JBRr7gX63nO6kAmImmYIAtuQblZ36Z+LfCjE=
-go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
-go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
-go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
-go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
-go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
-go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
-go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
-go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
-go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
-go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
-go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
-go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
+go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
+go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
+go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
+go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
+go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -432,36 +408,28 @@ go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
-golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
-golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
+golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
-golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
@@ -469,31 +437,25 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
-golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
-golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
-golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -501,8 +463,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -514,7 +476,6 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@@ -523,45 +484,39 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
-golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
-golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
-golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
-gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
-google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
-google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA=
+google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
-google.golang.org/genproto v0.0.0-20260217200457-a2cb2272a1e9 h1:MzLVemxGdOBt2uziz9LnuYRQQFw1FDV0s0af4GVYE1A=
-google.golang.org/genproto v0.0.0-20260217200457-a2cb2272a1e9/go.mod h1:9mSgs6f8tLwHSr6EzFWG+naa04gb1Zpt4IumYKsRDs0=
-google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s=
-google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
-google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/genproto v0.0.0-20260401024825-9d38bb4040a9 h1:w8JYjr7zHemS95YA5FFwk+fUv5tdQU4I8twN9bFdxVU=
+google.golang.org/genproto v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:YCEC8W7HTtK7iBv+pI7g7hGAi7qdGB6bQXw3BIYAusM=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
+google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M=
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/entities/message.go b/api/pkg/entities/message.go
index b7d423fb..52a9a221 100644
--- a/api/pkg/entities/message.go
+++ b/api/pkg/entities/message.go
@@ -90,7 +90,7 @@ type Message struct {
UserID UserID `json:"user_id" gorm:"index:idx_messages__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
Contact string `json:"contact" example:"+18005550100"`
Content string `json:"content" example:"This is a sample text message"`
- Attachments pq.StringArray `json:"attachments" gorm:"type:text[]" swaggertype:"array,string"`
+ Attachments pq.StringArray `json:"attachments" gorm:"type:text[]" swaggertype:"array,string" example:"https://example.com/image.jpg,https://example.com/video.mp4"`
Encrypted bool `json:"encrypted" example:"false" gorm:"default:false"`
Type MessageType `json:"type" example:"mobile-terminated"`
Status MessageStatus `json:"status" example:"pending"`
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/requests/message_send_request.go b/api/pkg/requests/message_send_request.go
index bebf6f48..727cc12e 100644
--- a/api/pkg/requests/message_send_request.go
+++ b/api/pkg/requests/message_send_request.go
@@ -19,7 +19,7 @@ type MessageSend struct {
Content string `json:"content" example:"This is a sample text message"`
// Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS
- Attachments []string `json:"attachments" validate:"optional"`
+ Attachments []string `json:"attachments" validate:"optional" example:"https://example.com/image.jpg,https://example.com/video.mp4"`
// Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app
Encrypted bool `json:"encrypted" example:"false" validate:"optional"`
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: