diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..dd234131 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,141 @@ +# Copilot Instructions for httpSMS + +httpSMS is a service that turns an Android phone into an SMS gateway via an HTTP API. This is a monorepo with three components: + +- **`api/`** — Go backend (Fiber, GORM, PostgreSQL) +- **`web/`** — Nuxt 2 frontend (Vue 2, Vuetify 2, TypeScript) +- **`android/`** — Native Android app (Kotlin) + +## Build, Test, and Lint Commands + +### API (Go) + +```bash +cd api + +# Development with hot-reload +air + +# Build +go build -o ./tmp/main.exe . + +# Run tests +go test ./... + +# Run a single test +go test ./pkg/services/ -run TestMessageService + +# Generate Swagger docs (required after changing API annotations) +swag init --requiredByDefault --parseDependency --parseInternal + +# Pre-commit hooks run: go-fumpt, go-imports, go-lint, go-mod-tidy +``` + +### Web (Nuxt/Vue) + +```bash +cd web + +# Install dependencies +pnpm install + +# Development server (port 3000) +pnpm dev + +# Lint (eslint + stylelint + prettier) +pnpm lint + +# Auto-fix lint issues +pnpm lintfix + +# Run tests (Jest) +pnpm test + +# Static site generation (production build) +pnpm run generate + +# Regenerate TypeScript API models from Swagger +pnpm api:models +``` + +### Android (Kotlin) + +```bash +cd android + +# Build +./gradlew build + +# Debug APK +./gradlew assembleDebug + +# Release APK +./gradlew assembleRelease +``` + +### Docker (full stack) + +```bash +# Start all services (PostgreSQL, Redis, API, Web) +docker compose up --build +# API at localhost:8000, Web at localhost:3000 +``` + +## Architecture + +### API — Layered Architecture with Event-Driven Processing + +The API uses a **DI container** (`pkg/di/container.go`) that lazily initializes all services as singletons. The layered architecture flows as: + +**Handlers → Services → Repositories → GORM/PostgreSQL** + +- **Handlers** (`pkg/handlers/`) — Fiber HTTP handlers. Each has a `RegisterRoutes()` method and embeds a base `handler` struct with standardized response methods (`responseBadRequest`, `responseNotFound`, etc.). +- **Services** (`pkg/services/`) — Business logic. Orchestrate repositories and dispatch events. +- **Repositories** (`pkg/repositories/`) — Data access via GORM. Interfaces defined alongside GORM implementations (prefixed `gorm*`). +- **Validators** (`pkg/validators/`) — One validator per handler, return `url.Values` for field errors. +- **Entities** (`pkg/entities/`) — Domain models, auto-migrated by GORM. + +**Event system**: Uses CloudEvents spec (`cloudevents/sdk-go`). Events defined in `pkg/events/` (31 event types). Listeners in `pkg/listeners/` process events either synchronously or via Google Cloud Tasks queue (emulator mode for local dev). + +**Entry point**: `main.go` loads `.env` in local mode, creates the DI container, and starts Fiber on `APP_PORT`. + +### Web — Nuxt 2 Static SPA + +- **State management**: Single Vuex store (`store/index.ts`) — actions make API calls via Axios, mutations update state, getters expose computed values. +- **Components**: Use `vue-property-decorator` class syntax with `@Component`, `@Prop`, `@Watch` decorators. +- **API client**: Axios configured in `plugins/axios.ts` with Firebase bearer token auth and `x-api-key` header support. +- **API models**: TypeScript types in `models/` are auto-generated from the Swagger spec via `swagger-typescript-api`. +- **Auth**: Firebase Authentication (Email/Password, Google, GitHub) with `auth` and `guest` middleware for route guards. +- **Real-time**: Pusher.js for live message updates. + +### Android — Task-Oriented, Event-Driven + +- **No MVVM/Clean Architecture** — uses a flat package structure with Activities, Services, BroadcastReceivers, and WorkManager tasks. +- **FCM integration**: `MyFirebaseMessagingService` receives push notifications → schedules `SendSmsWorker` via WorkManager → fetches message from API → sends SMS. +- **Dual SIM support**: Independent settings per SIM via `Settings` singleton (SharedPreferences). +- **HTTP client**: OkHttp with `x-api-key` authentication against the API. +- **Encryption**: AES-256/CFB with SHA-256 key derivation (`Encrypter.kt`). + +## Key Conventions + +### API (Go) + +- **Error handling**: Use `github.com/palantir/stacktrace` — wrap errors with `stacktrace.Propagate(err, "context")` or `stacktrace.PropagateWithCode()`. Never return bare errors. +- **Database queries**: Always use GORM query builder with context propagation (`repository.db.WithContext(ctx)`). No raw SQL. +- **Route registration**: Each handler defines `RegisterRoutes()` called from the DI container. Routes follow REST conventions under `/v1/`. +- **Middleware chain**: HTTP Logger → OpenTelemetry → CORS → Request Logger → Bearer Auth → API Key Auth. +- **Observability**: All layers are instrumented with OpenTelemetry (Fiber, GORM, Redis). Pass `logger` and `tracer` to constructors. +- **Code formatting**: `go-fumpt` (not `gofmt`), enforced via pre-commit hooks. + +### Web (Vue/TypeScript) + +- **Formatting**: No semicolons, single quotes, 2-space indentation (Prettier + ESLint). +- **Component style**: Class-based with `vue-property-decorator`, not Options API (though some pages use `Vue.extend()`). +- **Store pattern**: Actions handle async API calls and commit mutations. Access store from components via `this.$store`. + +### Android (Kotlin) + +- **API calls**: Use `HttpSmsApiService` singleton (static `create()` factory). OkHttp client with `x-api-key` header. +- **Background work**: Use WorkManager for tasks that must survive process death. Direct `Thread { }` for lightweight background ops. +- **State**: `Settings` object (SharedPreferences singleton) for all persistent state. +- **Phone number formatting**: Use `libphonenumber` for E.164 format validation. 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/.mcp.json b/.mcp.json new file mode 100644 index 00000000..1bb33a71 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,22 @@ +{ + "mcpServers": { + "playwright": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-playwright", + "--base-url", + "http://localhost:3000" + ], + "env": { + "BROWSER": "chromium" + } + }, + "context7": { + "type": "stdio", + "command": "npx", + "args": ["@upstash/context7-mcp@latest"] + } + } +} diff --git a/README.md b/README.md index 49c41f9a..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,12 +181,12 @@ 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` ```bash -cp web/.env.local.docker web/.env.local +cp web/.env.docker web/.env ``` - Update the environment variables in the `.env` file in the `web` directory with your firebase web SDK configuration in step 1 above @@ -190,15 +199,18 @@ 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` ```bash -cp api/.env.local.docker api/.env.local +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,12 +240,18 @@ 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. + + ```SQL + INSERT INTO users (id, api_key, email ) VALUES ('your-system-user-id', 'your-system-api-key', 'system@domain.com'); + ``` -- 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 +> [!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/build.gradle b/android/app/build.gradle deleted file mode 100644 index f49acb2b..00000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,73 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'com.google.gms.google-services' - id "io.sentry.android.gradle" version "4.3.1" -} - -def getGitHash = { -> - def stdout = new ByteArrayOutputStream() - exec { - commandLine 'git', 'rev-parse', '--short', 'HEAD' - standardOutput = stdout - } - return stdout.toString().trim() -} - -android { - compileSdk 35 - - defaultConfig { - applicationId "com.httpsms" - minSdk 28 - targetSdk 35 - versionCode 1 - versionName "${getGitHash()}" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - debug { - manifestPlaceholders["sentryEnvironment"] = "development" - } - release { - manifestPlaceholders["sentryEnvironment"] = "production" - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } - namespace 'com.httpsms' - - buildFeatures { - buildConfig = true - } -} - -dependencies { - implementation platform('com.google.firebase:firebase-bom:33.13.0') - implementation 'com.journeyapps:zxing-android-embedded:4.3.0' - implementation 'com.google.firebase:firebase-analytics-ktx' - implementation 'com.google.firebase:firebase-messaging-ktx' - implementation 'com.squareup.okhttp3:okhttp:4.12.0' - implementation 'com.jakewharton.timber:timber:5.0.1' - implementation 'androidx.preference:preference-ktx:1.2.1' - implementation 'androidx.work:work-runtime-ktx:2.10.1' - implementation 'androidx.core:core-ktx:1.16.0' - implementation "androidx.cardview:cardview:1.0.0" - implementation 'com.beust:klaxon:5.6' - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'org.apache.commons:commons-text:1.12.0' - implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.constraintlayout:constraintlayout:2.2.1' - implementation 'com.googlecode.libphonenumber:libphonenumber:9.0.4' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' -} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..15857e70 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + id("com.android.application") + id("com.google.gms.google-services") + id("io.sentry.android.gradle") version "6.2.0" +} + +val gitHash = providers.exec { + commandLine("git", "rev-parse", "--short", "HEAD") +}.standardOutput.asText.map { it.trim() } + +android { + compileSdk = 36 + + defaultConfig { + applicationId = "com.httpsms" + minSdk = 28 + targetSdk = 36 + versionCode = 1 + versionName = gitHash.getOrElse("unknown") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + getByName("debug") { + manifestPlaceholders["sentryEnvironment"] = "development" + } + getByName("release") { + manifestPlaceholders["sentryEnvironment"] = "production" + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + namespace = "com.httpsms" + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(platform("com.google.firebase:firebase-bom:34.11.0")) + implementation("com.journeyapps:zxing-android-embedded:4.3.0") + implementation("com.google.firebase:firebase-analytics") + implementation("com.google.firebase:firebase-messaging") + implementation("com.squareup.okhttp3:okhttp:5.3.2") + implementation("com.jakewharton.timber:timber:5.0.1") + implementation("androidx.preference:preference-ktx:1.2.1") + implementation("androidx.work:work-runtime-ktx:2.11.1") + implementation("androidx.core:core-ktx:1.18.0") + implementation("androidx.cardview:cardview:1.0.0") + implementation("com.beust:klaxon:5.6") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("org.apache.commons:commons-text:1.15.0") + implementation("com.google.android.material:material:1.13.0") + implementation("androidx.constraintlayout:constraintlayout:2.2.1") + implementation("com.googlecode.libphonenumber:libphonenumber:9.0.26") + implementation("com.klinkerapps:android-smsmms:5.2.6") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6d704ade..86ca0a51 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + @@ -30,7 +31,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.HttpSMS" - tools:targetApi="31"> + tools:targetApi="36"> + android:name="com.journeyapps.barcodescanner.CaptureActivity" + android:screenOrientation="fullSensor" + tools:replace="screenOrientation" + tools:ignore="DiscouragedApi" /> + + + + @@ -90,6 +95,17 @@ + + + + + diff --git a/android/app/src/main/java/com/httpsms/Constants.kt b/android/app/src/main/java/com/httpsms/Constants.kt index fd8a0b90..ba3e1584 100644 --- a/android/app/src/main/java/com/httpsms/Constants.kt +++ b/android/app/src/main/java/com/httpsms/Constants.kt @@ -10,6 +10,7 @@ class Constants { const val KEY_MESSAGE_TIMESTAMP = "KEY_MESSAGE_TIMESTAMP" const val KEY_MESSAGE_REASON = "KEY_MESSAGE_REASON" const val KEY_MESSAGE_ENCRYPTED = "KEY_MESSAGE_ENCRYPTED" + const val KEY_MESSAGE_ATTACHMENTS = "KEY_MESSAGE_ATTACHMENTS" const val KEY_HEARTBEAT_ID = "KEY_HEARTBEAT_ID" @@ -18,5 +19,7 @@ class Constants { const val SIM2 = "SIM2" const val TIMESTAMP_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'000000'ZZZZZ" + + const val MAX_MMS_ATTACHMENT_SIZE: Long = (3L * 1024 * 1024) / 2 } } diff --git a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt index 8f1e448c..e4113289 100644 --- a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt +++ b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt @@ -9,6 +9,15 @@ import com.google.firebase.messaging.RemoteMessage import com.httpsms.SentReceiver.FailedMessageWorker import timber.log.Timber +import com.google.android.mms.pdu_alt.CharacterSets +import com.google.android.mms.pdu_alt.EncodedStringValue +import com.google.android.mms.pdu_alt.PduBody +import com.google.android.mms.pdu_alt.PduComposer +import com.google.android.mms.pdu_alt.PduPart +import com.google.android.mms.pdu_alt.SendReq +import okhttp3.MediaType +import java.io.File + class MyFirebaseMessagingService : FirebaseMessagingService() { // [START receive_message] override fun onMessageReceived(remoteMessage: RemoteMessage) { @@ -158,6 +167,11 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { } Receiver.register(applicationContext) + + if (message.attachments != null && message.attachments.isNotEmpty()) { + return handleMmsMessage(message) + } + val parts = getMessageParts(applicationContext, message) if (parts.size == 1) { return handleSingleMessage(message, parts.first()) @@ -165,6 +179,143 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { return handleMultipartMessage(message, parts) } + fun extractFileName(url: String, prefix: String, mimeType: String? = null): String { + val fileName = url.substringAfterLast("/") + .substringBefore("?") + .takeIf { it.isNotBlank() && it.contains(".") } + ?: run { + val extension = mimeType?.let { mime -> + val ext = mime.substringAfterLast("/") + if (ext.isNotBlank()) ".$ext" else ".bin" + } ?: "" + "attachment$extension" + } + + return "${prefix}_$fileName" + } + + private fun handleMmsMessage(message: Message): Result { + Timber.d("Processing MMS for message ID [${message.id}]") + val apiService = HttpSmsApiService.create(applicationContext) + + val downloadedFiles = mutableListOf>() + + try { + for ((index, attachment) in message.attachments!!.withIndex()) { + val file = apiService.downloadAttachment(applicationContext, attachment, message.id, index) + if (file.first == null || file.second == null) { + handleFailed(applicationContext, message.id, "Failed to download attachment or file size exceeded 1.5MB.") + return Result.failure() + } + downloadedFiles.add(Pair(file.first!!, file.second!!)) + } + + val sendReq = SendReq() + + val encodedContact = EncodedStringValue(message.contact) + sendReq.to = arrayOf(encodedContact) + + val pduBody = PduBody() + + if (message.content.isNotEmpty()) { + val textPart = PduPart() + textPart.setCharset(CharacterSets.UTF_8) + textPart.contentType = "text/plain".toByteArray() + textPart.name = "text".toByteArray() + textPart.contentId = "text".toByteArray() + textPart.contentLocation = "text".toByteArray() + + var messageBody = message.content + val encryptionKey = Settings.getEncryptionKey(applicationContext) + if (message.encrypted && !encryptionKey.isNullOrEmpty()) { + messageBody = Encrypter.decrypt(encryptionKey, messageBody) + } + textPart.data = messageBody.toByteArray(Charsets.UTF_8) + + pduBody.addPart(textPart) + } + + for ((index, file) in downloadedFiles.withIndex()) { + val fileBytes = file.first.readBytes() + + val mediaPart = PduPart() + mediaPart.contentType = file.second.toString().toByteArray() + + + val fileName = extractFileName(message.attachments[index], index.toString(), file.second.toString()) + mediaPart.name = fileName.toByteArray() + mediaPart.contentId = fileName.toByteArray() + mediaPart.contentLocation = fileName.toByteArray() + mediaPart.data = fileBytes + + Timber.d("Adding MMS attachment with name [$fileName] and size [${fileBytes.size}] and type [${file.second}]") + + pduBody.addPart(mediaPart) + } + + sendReq.body = pduBody + + val pduComposer = PduComposer(applicationContext, sendReq) + val pduBytes = pduComposer.make() + + if (pduBytes == null) { + Timber.e("PduComposer failed to generate PDU byte array") + handleFailed(applicationContext, message.id, "Failed to compose MMS PDU.") + return Result.failure() + } + + val mmsDir = java.io.File(applicationContext.cacheDir, "mms_attachments") + if (!mmsDir.exists()) { + mmsDir.mkdirs() + } + + val pduFile = java.io.File(mmsDir, "pdu_${message.id}.dat") + java.io.FileOutputStream(pduFile).use { it.write(pduBytes) } + + val pduUri = androidx.core.content.FileProvider.getUriForFile( + applicationContext, + "${BuildConfig.APPLICATION_ID}.fileprovider", + pduFile + ) + + val sentIntent = createPendingIntent(message.id, SmsManagerService.sentAction()) + SmsManagerService().sendMultimediaMessage(applicationContext, pduUri, message.sim, sentIntent) + + Timber.d("Successfully dispatched MMS for message ID [${message.id}]") + return Result.success() + + } catch (e: Exception) { + Timber.e(e, "Failed to send MMS for message ID [${message.id}]") + handleFailed(applicationContext, message.id, e.message ?: "Internal error while building or sending MMS.") + return Result.failure() + } finally { + // Clean up any downloaded temporary files + downloadedFiles.forEach { file -> + if (file.first.exists()) { + file.first.delete() + } + } + + // Also clean up the MMS PDU file to avoid cache buildup in cases where + // sendMultimediaMessage fails before the sent broadcast is delivered. + try { + // The PDU file is stored under the "mms_attachments" cache subdirectory; + // delete it from the same location to ensure cleanup is effective. + val pduDir = File(applicationContext.cacheDir, "mms_attachments") + val pduFile = File(pduDir, "pdu_${message.id}.dat") + if (pduFile.exists()) { + val deleted = pduFile.delete() + if (!deleted) { + Timber.w("Failed to delete MMS PDU file for message ID [${message.id}] at [${pduFile.absolutePath}]") + } + } + } catch (cleanupException: Exception) { + // Best-effort cleanup; log but do not change the original result. + Timber.w(cleanupException, "Error while cleaning up MMS PDU file for message ID [${message.id}]") + } + } + } + private fun handleMultipartMessage(message:Message, parts: ArrayList): Result { Timber.d("sending multipart SMS for message with ID [${message.id}]") return try { diff --git a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt index 3d813e13..51fa21cd 100644 --- a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt +++ b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt @@ -1,12 +1,18 @@ package com.httpsms import android.content.Context +import com.httpsms.Constants.Companion.MAX_MMS_ATTACHMENT_SIZE +import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import org.apache.commons.text.StringEscapeUtils import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream import java.net.URI import java.net.URL import java.util.logging.Level @@ -68,17 +74,8 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { return sendEvent(messageId, "FAILED", timestamp, reason) } - fun receive(sim: String, from: String, to: String, content: String, encrypted: Boolean, timestamp: String): Boolean { - val body = """ - { - "content": "${StringEscapeUtils.escapeJson(content)}", - "sim": "$sim", - "from": "$from", - "timestamp": "$timestamp", - "encrypted": $encrypted, - "to": "$to" - } - """.trimIndent() + fun receive(requestPayload: ReceivedMessageRequest): Boolean { + val body = com.beust.klaxon.Klaxon().toJsonString(requestPayload) val request: Request = Request.Builder() .url(resolveURL("/v1/messages/receive")) @@ -87,16 +84,21 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { .header(clientVersionHeader, BuildConfig.VERSION_NAME) .build() - val response = client.newCall(request).execute() + val response = try { + client.newCall(request).execute() + } catch (e: Exception) { + Timber.e(e, "Exception while sending received message request") + return false + } + if (!response.isSuccessful) { - Timber.e("error response [${response.body?.string()}] with code [${response.code}] while receiving message [${body}]") + Timber.e("error response [${response.body?.string()}] with code [${response.code}] while receiving message") response.close() return response.code in 400..499 } - val message = ResponseMessage.fromJson(response.body!!.string()) response.close() - Timber.i("received message stored successfully for message with ID [${message?.data?.id}]" ) + Timber.i("received message stored successfully") return true } @@ -156,6 +158,65 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { return true } + fun InputStream.copyToWithLimit( + out: OutputStream, + limit: Long, + bufferSize: Int = DEFAULT_BUFFER_SIZE + ): Long { + var bytesCopied: Long = 0 + val buffer = ByteArray(bufferSize) + var bytes = read(buffer) + + while (bytes >= 0) { + bytesCopied += bytes + + if (bytesCopied > limit) { + throw IOException("Download aborted: File exceeded maximum allowed size of $limit bytes.") + } + + out.write(buffer, 0, bytes) + bytes = read(buffer) + } + return bytesCopied + } + + fun downloadAttachment(context: Context, urlString: String, messageId: String, attachmentIndex: Int): Pair { + val request = Request.Builder().url(urlString).build() + + try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.e("Failed to download attachment: ${response.code}") + return Pair(null, null) + } + + val body = response.body + val contentLength = body.contentLength() + if (contentLength > MAX_MMS_ATTACHMENT_SIZE) { + Timber.e("Attachment is too large ($contentLength bytes).") + return Pair(null, null) + } + + val mmsDir = File(context.cacheDir, "mms_attachments") + if (!mmsDir.exists()) { + mmsDir.mkdirs() + } + + val tempFile = File(mmsDir, "mms_${messageId}_$attachmentIndex") + val inputStream = body.byteStream() + FileOutputStream(tempFile).use { outputStream -> + inputStream.use { input -> + input.copyToWithLimit(outputStream, MAX_MMS_ATTACHMENT_SIZE) + } + } + + return Pair(tempFile, body.contentType()) + } + } catch (e: Exception) { + Timber.e(e, "Exception while download attachment") + return Pair(null, null) + } + } private fun sendEvent(messageId: String, event: String, timestamp: String, reason: String? = null): Boolean { var reasonString = "null" @@ -186,7 +247,7 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { } if (!response.isSuccessful) { - Timber.e("error response [${response.body?.string()}] with code [${response.code}] while sending [${event}] event [${body}] for message with ID [${messageId}]") + Timber.e("error response [${response.body.string()}] with code [${response.code}] while sending [${event}] event [${body}] for message with ID [${messageId}]") response.close() return false } diff --git a/android/app/src/main/java/com/httpsms/MainActivity.kt b/android/app/src/main/java/com/httpsms/MainActivity.kt index 5f76ada8..363e7c19 100644 --- a/android/app/src/main/java/com/httpsms/MainActivity.kt +++ b/android/app/src/main/java/com/httpsms/MainActivity.kt @@ -6,6 +6,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -28,7 +29,6 @@ import com.google.android.material.card.MaterialCardView import com.google.android.material.progressindicator.LinearProgressIndicator import com.httpsms.services.StickyNotificationService import com.httpsms.worker.HeartbeatWorker -import okhttp3.internal.format import timber.log.Timber import java.time.Instant import java.time.ZoneId @@ -60,6 +60,7 @@ class MainActivity : AppCompatActivity() { scheduleHeartbeatWorker(this) setVersion() setHeartbeatListener(this) + setSmsPermissionListener() setBatteryOptimizationListener() } @@ -74,12 +75,13 @@ class MainActivity : AppCompatActivity() { redirectToLogin() refreshToken(this) setCardContent(this) + setSmsPermissionListener() setBatteryOptimizationListener() } private fun setVersion() { val appVersionView = findViewById(R.id.mainAppVersion) - appVersionView.text = format(getString(R.string.app_version), BuildConfig.VERSION_NAME) + appVersionView.text = getString(R.string.app_version, BuildConfig.VERSION_NAME) } private fun setCardContent(context: Context) { @@ -114,6 +116,7 @@ class MainActivity : AppCompatActivity() { Settings.setIncomingCallEventsEnabled(context, Constants.SIM2, false) } } + setSmsPermissionListener() } var permissions = arrayOf( @@ -283,8 +286,9 @@ class MainActivity : AppCompatActivity() { @SuppressLint("BatteryLife") private fun setBatteryOptimizationListener() { val pm = getSystemService(POWER_SERVICE) as PowerManager + val button = findViewById(R.id.batteryOptimizationButtonButton) if (!pm.isIgnoringBatteryOptimizations(packageName)) { - val button = findViewById(R.id.batteryOptimizationButtonButton) + button.visibility = View.VISIBLE button.setOnClickListener { val intent = Intent() intent.action = ProviderSettings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS @@ -292,8 +296,43 @@ class MainActivity : AppCompatActivity() { startActivity(intent) } } else { - val layout = findViewById(R.id.batteryOptimizationLinearLayout) + button.visibility = View.GONE + } + updatePermissionLayoutVisibility() + } + + private fun setSmsPermissionListener() { + val smsPermissions = arrayOf( + Manifest.permission.SEND_SMS, + Manifest.permission.RECEIVE_SMS, + Manifest.permission.READ_SMS + ) + val allGranted = smsPermissions.all { + checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } + + val button = findViewById(R.id.smsPermissionButton) + if (!allGranted) { + button.visibility = View.VISIBLE + button.setOnClickListener { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://httpsms.com/blog/grant-send-and-read-sms-permissions-on-android")) + startActivity(intent) + } + } else { + button.visibility = View.GONE + } + updatePermissionLayoutVisibility() + } + + private fun updatePermissionLayoutVisibility() { + val smsButton = findViewById(R.id.smsPermissionButton) + val batteryButton = findViewById(R.id.batteryOptimizationButtonButton) + val layout = findViewById(R.id.batteryOptimizationLinearLayout) + + if (smsButton.visibility == View.GONE && batteryButton.visibility == View.GONE) { layout.visibility = View.GONE + } else { + layout.visibility = View.VISIBLE } } diff --git a/android/app/src/main/java/com/httpsms/Models.kt b/android/app/src/main/java/com/httpsms/Models.kt index ccfe590b..b4bf5464 100644 --- a/android/app/src/main/java/com/httpsms/Models.kt +++ b/android/app/src/main/java/com/httpsms/Models.kt @@ -68,5 +68,24 @@ data class Message ( val type: String, @Json(name = "updated_at") - val updatedAt: String + val updatedAt: String, + + val attachments: List? = null +) + +data class ReceivedAttachment( + val name: String, + @Json(name = "content_type") + val contentType: String, + val content: String +) + +data class ReceivedMessageRequest( + val sim: String, + val from: String, + val to: String, + val content: String, + val encrypted: Boolean, + val timestamp: String, + val attachments: List? = null ) diff --git a/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt b/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt index 9d0f3d83..3edc30e2 100644 --- a/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt +++ b/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt @@ -4,7 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.provider.Telephony -import androidx.work.BackoffPolicy +import android.util.Base64 import androidx.work.Constraints import androidx.work.Data import androidx.work.NetworkType @@ -13,20 +13,30 @@ import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters import androidx.work.workDataOf +import com.google.android.mms.pdu_alt.CharacterSets +import com.google.android.mms.pdu_alt.MultimediaMessagePdu +import com.google.android.mms.pdu_alt.PduParser +import com.google.android.mms.pdu_alt.RetrieveConf import timber.log.Timber +import java.io.File +import java.io.FileOutputStream import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import java.util.concurrent.TimeUnit class ReceivedReceiver: BroadcastReceiver() { - override fun onReceive(context: Context,intent: Intent) { - if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Telephony.Sms.Intents.SMS_RECEIVED_ACTION) { + handleSmsReceived(context, intent) + } else if (intent.action == Telephony.Sms.Intents.WAP_PUSH_RECEIVED_ACTION) { + handleMmsReceived(context, intent) + } else { Timber.e("received invalid intent with action [${intent.action}]") - return } + } + private fun handleSmsReceived(context: Context, intent: Intent) { var smsSender = "" var smsBody = "" @@ -35,12 +45,7 @@ class ReceivedReceiver: BroadcastReceiver() smsBody += smsMessage.messageBody } - var sim = Constants.SIM1 - var owner = Settings.getSIM1PhoneNumber(context) - if (intent.getIntExtra("android.telephony.extra.SLOT_INDEX", 0) > 0 && Settings.isDualSIM(context)) { - owner = Settings.getSIM2PhoneNumber(context) - sim = Constants.SIM2 - } + val (sim, owner) = getSimAndOwner(context, intent) if (!Settings.isIncomingMessageEnabled(context, sim)) { Timber.w("[${sim}] is not active for incoming messages") @@ -56,7 +61,71 @@ class ReceivedReceiver: BroadcastReceiver() ) } - private fun handleMessageReceived(context: Context, sim: String, from: String, to : String, content: String) { + private fun handleMmsReceived(context: Context, intent: Intent) { + val pushData = intent.getByteArrayExtra("data") ?: return + val pdu = PduParser(pushData, true).parse() ?: return + + if (pdu !is MultimediaMessagePdu) { + Timber.d("Received PDU is not a MultimediaMessagePdu, ignoring.") + return + } + + val from = pdu.from?.string ?: "" + var content = "" + val attachmentFiles = mutableListOf() + + // Check if it's a RetrieveConf (which contains the actual message body) + if (pdu is RetrieveConf) { + val body = pdu.body + if (body != null) { + for (i in 0 until body.partsNum) { + val part = body.getPart(i) + val partData = part.data ?: continue + val contentType = String(part.contentType ?: "application/octet-stream".toByteArray()) + + if (contentType.startsWith("text/plain")) { + content += String(partData, charset(CharacterSets.getMimeName(part.charset))) + } else { + // Save attachment to a temporary file + val fileName = String(part.name ?: part.contentLocation ?: part.contentId ?: "attachment_$i".toByteArray()) + val tempFile = File(context.cacheDir, "received_mms_${System.currentTimeMillis()}_$i") + FileOutputStream(tempFile).use { it.write(partData) } + attachmentFiles.add("${tempFile.absolutePath}|${contentType}|${fileName}") + } + } + } + } else { + Timber.d("Received PDU is of type [${pdu.javaClass.simpleName}], body extraction not implemented.") + } + + val (sim, owner) = getSimAndOwner(context, intent) + + if (!Settings.isIncomingMessageEnabled(context, sim)) { + Timber.w("[${sim}] is not active for incoming messages") + return + } + + handleMessageReceived( + context, + sim, + from, + owner, + content, + attachmentFiles.toTypedArray() + ) + } + + private fun getSimAndOwner(context: Context, intent: Intent): Pair { + var sim = Constants.SIM1 + var owner = Settings.getSIM1PhoneNumber(context) + if (intent.getIntExtra("android.telephony.extra.SLOT_INDEX", 0) > 0 && Settings.isDualSIM(context)) { + owner = Settings.getSIM2PhoneNumber(context) + sim = Constants.SIM2 + } + return Pair(sim, owner) + } + + private fun handleMessageReceived(context: Context, sim: String, from: String, to : String, content: String, attachments: Array? = null) { val timestamp = ZonedDateTime.now(ZoneOffset.UTC) if (!Settings.isLoggedIn(context)) { @@ -84,7 +153,8 @@ class ReceivedReceiver: BroadcastReceiver() Constants.KEY_MESSAGE_SIM to sim, Constants.KEY_MESSAGE_CONTENT to body, Constants.KEY_MESSAGE_ENCRYPTED to Settings.encryptReceivedMessages(context), - Constants.KEY_MESSAGE_TIMESTAMP to DateTimeFormatter.ofPattern(Constants.TIMESTAMP_PATTERN).format(timestamp).replace("+", "Z") + Constants.KEY_MESSAGE_TIMESTAMP to DateTimeFormatter.ofPattern(Constants.TIMESTAMP_PATTERN).format(timestamp).replace("+", "Z"), + Constants.KEY_MESSAGE_ATTACHMENTS to attachments ) val work = OneTimeWorkRequest @@ -104,14 +174,52 @@ class ReceivedReceiver: BroadcastReceiver() override fun doWork(): Result { Timber.i("[${this.inputData.getString(Constants.KEY_MESSAGE_SIM)}] forwarding received message from [${this.inputData.getString(Constants.KEY_MESSAGE_FROM)}] to [${this.inputData.getString(Constants.KEY_MESSAGE_TO)}]") - if (HttpSmsApiService.create(applicationContext).receive( - this.inputData.getString(Constants.KEY_MESSAGE_SIM)!!, - this.inputData.getString(Constants.KEY_MESSAGE_FROM)!!, - this.inputData.getString(Constants.KEY_MESSAGE_TO)!!, - this.inputData.getString(Constants.KEY_MESSAGE_CONTENT)!!, - this.inputData.getBoolean(Constants.KEY_MESSAGE_ENCRYPTED, false), - this.inputData.getString(Constants.KEY_MESSAGE_TIMESTAMP)!!, - )) { + val sim = this.inputData.getString(Constants.KEY_MESSAGE_SIM)!! + val from = this.inputData.getString(Constants.KEY_MESSAGE_FROM)!! + val to = this.inputData.getString(Constants.KEY_MESSAGE_TO)!! + val content = this.inputData.getString(Constants.KEY_MESSAGE_CONTENT)!! + val encrypted = this.inputData.getBoolean(Constants.KEY_MESSAGE_ENCRYPTED, false) + val timestamp = this.inputData.getString(Constants.KEY_MESSAGE_TIMESTAMP)!! + + val attachmentsData = inputData.getStringArray(Constants.KEY_MESSAGE_ATTACHMENTS) + val attachments = attachmentsData?.mapNotNull { + val parts = it.split("|") + val file = File(parts[0]) + if (file.exists()) { + val bytes = file.readBytes() + val base64Content = Base64.encodeToString(bytes, Base64.NO_WRAP) + ReceivedAttachment( + name = parts[2], + contentType = parts[1], + content = base64Content + ) + } else { + null + } + } + + val request = ReceivedMessageRequest( + sim = sim, + from = from, + to = to, + content = content, + encrypted = encrypted, + timestamp = timestamp, + attachments = attachments + ) + + val success = HttpSmsApiService.create(applicationContext).receive(request) + + // Cleanup temp files + attachmentsData?.forEach { + val path = it.split("|")[0] + val file = File(path) + if (file.exists()) { + file.delete() + } + } + + if (success) { return Result.success() } diff --git a/android/app/src/main/java/com/httpsms/SentReceiver.kt b/android/app/src/main/java/com/httpsms/SentReceiver.kt index 7995c35c..8786ba2c 100644 --- a/android/app/src/main/java/com/httpsms/SentReceiver.kt +++ b/android/app/src/main/java/com/httpsms/SentReceiver.kt @@ -14,9 +14,12 @@ import androidx.work.Worker import androidx.work.WorkerParameters import androidx.work.workDataOf import timber.log.Timber +import java.io.File internal class SentReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { + val messageId = intent.getStringExtra(Constants.KEY_MESSAGE_ID) + cleanupPduFile(context, messageId) when (resultCode) { Activity.RESULT_OK -> handleMessageSent(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID)) SmsManager.RESULT_ERROR_GENERIC_FAILURE -> handleMessageFailed(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID), "GENERIC_FAILURE") @@ -27,6 +30,26 @@ internal class SentReceiver : BroadcastReceiver() { } } + private fun cleanupPduFile(context: Context, messageId: String?) { + if (messageId == null) return + + try { + val baseMessageId = messageId.substringBefore(".") + val mmsDir = File(context.cacheDir, "mms_attachments") + val pduFile = File(mmsDir, "pdu_$baseMessageId.dat") + + if (pduFile.exists()) { + if (pduFile.delete()) { + Timber.d("Cleaned up PDU file for message ID [$baseMessageId]") + } else { + Timber.w("Failed to delete PDU file for message ID [$baseMessageId]") + } + } + } catch (e: Exception) { + Timber.e(e, "Error cleaning up PDU file for message ID [$messageId]") + } + } + private fun handleMessageSent(context: Context, messageId: String?) { if (!Receiver.isValid(context, messageId)) { return diff --git a/android/app/src/main/java/com/httpsms/SmsManagerService.kt b/android/app/src/main/java/com/httpsms/SmsManagerService.kt index 17987b5c..5f7ce6f5 100644 --- a/android/app/src/main/java/com/httpsms/SmsManagerService.kt +++ b/android/app/src/main/java/com/httpsms/SmsManagerService.kt @@ -76,4 +76,10 @@ class SmsManagerService { context.getSystemService(SmsManager::class.java).createForSubscriptionId(subscriptionId) } } + + // Wrapper for the smsManager's sendMultimediaMessage + fun sendMultimediaMessage(context: Context, pduUri: android.net.Uri, sim: String, sentIntent: PendingIntent) { + val smsManager = getSmsManager(context, sim) + smsManager.sendMultimediaMessage(context, pduUri, null, null, sentIntent) + } } diff --git a/android/app/src/main/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/drawable/open_in_new_24.xml b/android/app/src/main/res/drawable/open_in_new_24.xml new file mode 100644 index 00000000..b257c344 --- /dev/null +++ b/android/app/src/main/res/drawable/open_in_new_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/layout/activity_login.xml b/android/app/src/main/res/layout/activity_login.xml index cfd86db6..03552be5 100644 --- a/android/app/src/main/res/layout/activity_login.xml +++ b/android/app/src/main/res/layout/activity_login.xml @@ -1,174 +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/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index d04b9150..75849475 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -3,9 +3,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:paddingLeft="16dp" - android:paddingRight="16dp" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".MainActivity"> - - @@ -46,8 +37,6 @@ android:orientation="vertical" android:padding="16dp"> - - - @@ -96,8 +86,6 @@ android:orientation="vertical" android:padding="16dp"> - - - + + + app:indicatorColor="@color/pink_500" /> + + + android:layout_height="match_parent" + android:fitsSystemWindows="true"> @@ -30,8 +27,10 @@ + android:layout_height="0dp" + android:fillViewport="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintTop_toBottomOf="@+id/settings_app_bar_layout"> + android:paddingRight="16dp"> #121212 - true + false diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 24e9609d..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 @@ -17,6 +17,7 @@ https://api.httpsms.com httpsms.com - %s Disable Battery Optimization + Enable SMS Permission App Settings SIM1 SIM2 diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 538ca49c..5914accc 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -13,7 +13,7 @@ #121212 - true + false diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..0df3af41 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index e29386c3..00000000 --- a/android/build.gradle +++ /dev/null @@ -1,27 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - ext { - kotlin_version = '2.1.0' - } - repositories { - // Check that you have the following line (if not, add it): - google() - mavenCentral() // Google's Maven repository - - } - dependencies { - // Add this line - classpath 'com.google.gms:google-services:4.4.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -plugins { - id 'com.android.application' version '8.9.2' apply false - id 'com.android.library' version '8.9.2' apply false - id 'org.jetbrains.kotlin.android' version '1.6.21' apply false -} - -tasks.register('clean', Delete) { - delete rootProject.buildDir -} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..32077a01 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,19 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.google.gms:google-services:4.4.2") + } +} + +plugins { + id("com.android.application") version "9.1.1" apply false + id("com.android.library") version "9.1.1" apply false +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties index cf0008dd..1f124546 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -22,3 +22,11 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.nonFinalResIds=false +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index f40abbca..2721b96b 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Jun 23 15:32:32 EEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/android/settings.gradle b/android/settings.gradle.kts similarity index 95% rename from android/settings.gradle rename to android/settings.gradle.kts index baf72e29..75be430a 100644 --- a/android/settings.gradle +++ b/android/settings.gradle.kts @@ -13,4 +13,4 @@ dependencyResolutionManagement { } } rootProject.name = "httpSMS" -include ':app' +include(":app") 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/Dockerfile b/api/Dockerfile index 34d82403..8e0206a2 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,4 +1,4 @@ -FROM golang as builder +FROM golang AS builder ARG GIT_COMMIT ENV GIT_COMMIT=$GIT_COMMIT diff --git a/api/docs/docs.go b/api/docs/docs.go index 908d34c3..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" @@ -11,7 +10,7 @@ const docTemplate = `{ "description": "{{escape .Description}}", "title": "{{.Title}}", "contact": { - "name": "HTTP SMS", + "name": "support@httpsms.com", "email": "support@httpsms.com" }, "license": { @@ -151,9 +150,9 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Sends bulk SMS messages to multiple users from a CSV file.", + "description": "Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).", "consumes": [ - "application/json" + "multipart/form-data" ], "produces": [ "application/json" @@ -162,6 +161,15 @@ const docTemplate = `{ "BulkSMS" ], "summary": "Store bulk SMS file", + "parameters": [ + { + "type": "file", + "description": "The Excel or CSV file containing the messages to be sent.", + "name": "document", + "in": "formData", + "required": true + } + ], "responses": { "202": { "description": "Accepted", @@ -701,53 +709,6 @@ const docTemplate = `{ } } }, - "/lemonsqueezy/event": { - "post": { - "description": "Publish a lemonsqueezy event to the registered listeners", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Lemonsqueezy" - ], - "summary": "Consume a lemonsqueezy event", - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, "/message-threads": { "get": { "security": [ @@ -1410,7 +1371,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Add a new SMS message to be sent by the android phone", + "description": "Add a new SMS message to be sent by your Android phone", "consumes": [ "application/json" ], @@ -1420,10 +1381,10 @@ const docTemplate = `{ "tags": [ "Messages" ], - "summary": "Send a new SMS message", + "summary": "Send an SMS message", "parameters": [ { - "description": "PostSend message request payload", + "description": "Send message request payload", "name": "payload", "in": "body", "required": true, @@ -1467,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": [ { @@ -2401,6 +2428,128 @@ const docTemplate = `{ } } }, + "/users/subscription/invoices/{subscriptionInvoiceID}": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Generates a new invoice PDF file for the given subscription payment with given parameters.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/pdf" + ], + "tags": [ + "Users" + ], + "summary": "Generate a subscription payment invoice", + "parameters": [ + { + "description": "Generate subscription payment invoice parameters", + "name": "payload", + "in": "body", + "required": true, + "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": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/users/subscription/payments": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get the last 10 subscription payments.", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserSubscriptionPaymentsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/users/{userID}/api-keys": { "delete": { "security": [ @@ -2534,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": [ @@ -2937,28 +3148,17 @@ const docTemplate = `{ "entities.Message": { "type": "object", "required": [ - "can_be_polled", + "attachments", "contact", "content", "created_at", - "delivered_at", "encrypted", - "expired_at", - "failed_at", - "failure_reason", "id", - "last_attempted_at", "max_send_attempts", "order_timestamp", "owner", - "received_at", - "request_id", "request_received_at", - "scheduled_at", - "scheduled_send_time", "send_attempt_count", - "send_time", - "sent_at", "sim", "status", "type", @@ -2966,9 +3166,15 @@ const docTemplate = `{ "user_id" ], "properties": { - "can_be_polled": { - "type": "boolean", - "example": false + "attachments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://example.com/image.jpg", + "https://example.com/video.mp4" + ] }, "contact": { "type": "string", @@ -3057,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": { @@ -3149,12 +3359,10 @@ const docTemplate = `{ "type": "object", "required": [ "created_at", - "fcm_token", "id", "max_send_attempts", "message_expiration_seconds", "messages_per_minute", - "missed_call_auto_reply", "phone_number", "sim", "updated_at", @@ -3195,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", @@ -3272,10 +3479,49 @@ 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": [ - "active_phone_id", "api_key", "created_at", "email", @@ -3284,11 +3530,8 @@ const docTemplate = `{ "notification_message_status_enabled", "notification_newsletter_enabled", "notification_webhook_enabled", - "subscription_ends_at", "subscription_id", "subscription_name", - "subscription_renews_at", - "subscription_status", "timezone", "updated_at" ], @@ -3338,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": { @@ -3473,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" @@ -3574,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" @@ -3589,7 +3874,11 @@ const docTemplate = `{ }, "sim": { "description": "SIM card that received the message", - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" + } + ], "example": "SIM1" }, "timestamp": { @@ -3611,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" @@ -3630,9 +3930,9 @@ const docTemplate = `{ "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" }, "send_at": { - "description": "SendAt is an optional parameter used to schedule a message to be sent at a later time", + "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future.", "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" + "example": "2025-12-19T16:39:57-08:00" }, "to": { "type": "string", @@ -3759,6 +4059,48 @@ const docTemplate = `{ } } }, + "requests.UserPaymentInvoice": { + "type": "object", + "required": [ + "address", + "city", + "country", + "name", + "notes", + "state", + "zip_code" + ], + "properties": { + "address": { + "type": "string", + "example": "221B Baker Street, London" + }, + "city": { + "type": "string", + "example": "Los Angeles" + }, + "country": { + "type": "string", + "example": "US" + }, + "name": { + "type": "string", + "example": "Acme Corp" + }, + "notes": { + "type": "string", + "example": "Thank you for your business!" + }, + "state": { + "type": "string", + "example": "CA" + }, + "zip_code": { + "type": "string", + "example": "9800" + } + } + }, "requests.UserUpdate": { "type": "object", "required": [ @@ -4300,6 +4642,156 @@ const docTemplate = `{ } } }, + "responses.UserSubscriptionPaymentsResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "attributes", + "id", + "type" + ], + "properties": { + "attributes": { + "type": "object", + "required": [ + "billing_reason", + "card_brand", + "card_last_four", + "created_at", + "currency", + "currency_rate", + "discount_total", + "discount_total_formatted", + "discount_total_usd", + "refunded", + "refunded_amount", + "refunded_amount_formatted", + "refunded_amount_usd", + "refunded_at", + "status", + "status_formatted", + "subtotal", + "subtotal_formatted", + "subtotal_usd", + "tax", + "tax_formatted", + "tax_inclusive", + "tax_usd", + "total", + "total_formatted", + "total_usd", + "updated_at" + ], + "properties": { + "billing_reason": { + "type": "string" + }, + "card_brand": { + "type": "string" + }, + "card_last_four": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "currency_rate": { + "type": "string" + }, + "discount_total": { + "type": "integer" + }, + "discount_total_formatted": { + "type": "string" + }, + "discount_total_usd": { + "type": "integer" + }, + "refunded": { + "type": "boolean" + }, + "refunded_amount": { + "type": "integer" + }, + "refunded_amount_formatted": { + "type": "string" + }, + "refunded_amount_usd": { + "type": "integer" + }, + "refunded_at": {}, + "status": { + "type": "string" + }, + "status_formatted": { + "type": "string" + }, + "subtotal": { + "type": "integer" + }, + "subtotal_formatted": { + "type": "string" + }, + "subtotal_usd": { + "type": "integer" + }, + "tax": { + "type": "integer" + }, + "tax_formatted": { + "type": "string" + }, + "tax_inclusive": { + "type": "boolean" + }, + "tax_usd": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_formatted": { + "type": "string" + }, + "total_usd": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, "responses.WebhookResponse": { "type": "object", "required": [ @@ -4362,9 +4854,11 @@ var SwaggerInfo = &swag.Spec{ BasePath: "/v1", Schemes: []string{"https"}, Title: "httpSMS API Reference", - Description: "API to send SMS messages using android [SmsManager](https://developer.android.com/reference/android/telephony/SmsManager) via HTTP", + 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 fb49b100..b8bc5739 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -2,10 +2,10 @@ "schemes": ["https"], "swagger": "2.0", "info": { - "description": "API to send SMS messages using android [SmsManager](https://developer.android.com/reference/android/telephony/SmsManager) via HTTP", + "description": "Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption.", "title": "httpSMS API Reference", "contact": { - "name": "HTTP SMS", + "name": "support@httpsms.com", "email": "support@httpsms.com" }, "license": { @@ -133,11 +133,20 @@ "ApiKeyAuth": [] } ], - "description": "Sends bulk SMS messages to multiple users from a CSV file.", - "consumes": ["application/json"], + "description": "Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).", + "consumes": ["multipart/form-data"], "produces": ["application/json"], "tags": ["BulkSMS"], "summary": "Store bulk SMS file", + "parameters": [ + { + "type": "file", + "description": "The Excel or CSV file containing the messages to be sent.", + "name": "document", + "in": "formData", + "required": true + } + ], "responses": { "202": { "description": "Accepted", @@ -629,47 +638,6 @@ } } }, - "/lemonsqueezy/event": { - "post": { - "description": "Publish a lemonsqueezy event to the registered listeners", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Lemonsqueezy"], - "summary": "Consume a lemonsqueezy event", - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, "/message-threads": { "get": { "security": [ @@ -1278,14 +1246,14 @@ "ApiKeyAuth": [] } ], - "description": "Add a new SMS message to be sent by the android phone", + "description": "Add a new SMS message to be sent by your Android phone", "consumes": ["application/json"], "produces": ["application/json"], "tags": ["Messages"], - "summary": "Send a new SMS message", + "summary": "Send an SMS message", "parameters": [ { - "description": "PostSend message request payload", + "description": "Send message request payload", "name": "payload", "in": "body", "required": true, @@ -1329,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": [ { @@ -2177,6 +2205,116 @@ } } }, + "/users/subscription/invoices/{subscriptionInvoiceID}": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Generates a new invoice PDF file for the given subscription payment with given parameters.", + "consumes": ["application/json"], + "produces": ["application/pdf"], + "tags": ["Users"], + "summary": "Generate a subscription payment invoice", + "parameters": [ + { + "description": "Generate subscription payment invoice parameters", + "name": "payload", + "in": "body", + "required": true, + "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": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/users/subscription/payments": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Get the last 10 subscription payments.", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserSubscriptionPaymentsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/users/{userID}/api-keys": { "delete": { "security": [ @@ -2298,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": [ @@ -2677,28 +2873,17 @@ "entities.Message": { "type": "object", "required": [ - "can_be_polled", + "attachments", "contact", "content", "created_at", - "delivered_at", "encrypted", - "expired_at", - "failed_at", - "failure_reason", "id", - "last_attempted_at", "max_send_attempts", "order_timestamp", "owner", - "received_at", - "request_id", "request_received_at", - "scheduled_at", - "scheduled_send_time", "send_attempt_count", - "send_time", - "sent_at", "sim", "status", "type", @@ -2706,9 +2891,15 @@ "user_id" ], "properties": { - "can_be_polled": { - "type": "boolean", - "example": false + "attachments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://example.com/image.jpg", + "https://example.com/video.mp4" + ] }, "contact": { "type": "string", @@ -2797,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": { @@ -2889,12 +3084,10 @@ "type": "object", "required": [ "created_at", - "fcm_token", "id", "max_send_attempts", "message_expiration_seconds", "messages_per_minute", - "missed_call_auto_reply", "phone_number", "sim", "updated_at", @@ -2935,8 +3128,7 @@ "example": "+18005550199" }, "sim": { - "description": "SIM card that received the message", - "type": "string" + "$ref": "#/definitions/entities.SIM" }, "updated_at": { "type": "string", @@ -3009,10 +3201,43 @@ } } }, + "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": [ - "active_phone_id", "api_key", "created_at", "email", @@ -3021,11 +3246,8 @@ "notification_message_status_enabled", "notification_newsletter_enabled", "notification_webhook_enabled", - "subscription_ends_at", "subscription_id", "subscription_name", - "subscription_renews_at", - "subscription_status", "timezone", "updated_at" ], @@ -3075,7 +3297,11 @@ "example": "8f9c71b8-b84e-4417-8408-a62274f65a08" }, "subscription_name": { - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SubscriptionName" + } + ], "example": "free" }, "subscription_renews_at": { @@ -3194,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" @@ -3271,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" @@ -3286,7 +3547,11 @@ }, "sim": { "description": "SIM card that received the message", - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" + } + ], "example": "SIM1" }, "timestamp": { @@ -3304,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" @@ -3323,9 +3599,9 @@ "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" }, "send_at": { - "description": "SendAt is an optional parameter used to schedule a message to be sent at a later time", + "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future.", "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" + "example": "2025-12-19T16:39:57-08:00" }, "to": { "type": "string", @@ -3444,6 +3720,48 @@ } } }, + "requests.UserPaymentInvoice": { + "type": "object", + "required": [ + "address", + "city", + "country", + "name", + "notes", + "state", + "zip_code" + ], + "properties": { + "address": { + "type": "string", + "example": "221B Baker Street, London" + }, + "city": { + "type": "string", + "example": "Los Angeles" + }, + "country": { + "type": "string", + "example": "US" + }, + "name": { + "type": "string", + "example": "Acme Corp" + }, + "notes": { + "type": "string", + "example": "Thank you for your business!" + }, + "state": { + "type": "string", + "example": "CA" + }, + "zip_code": { + "type": "string", + "example": "9800" + } + } + }, "requests.UserUpdate": { "type": "object", "required": ["active_phone_id", "timezone"], @@ -3885,6 +4203,148 @@ } } }, + "responses.UserSubscriptionPaymentsResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["attributes", "id", "type"], + "properties": { + "attributes": { + "type": "object", + "required": [ + "billing_reason", + "card_brand", + "card_last_four", + "created_at", + "currency", + "currency_rate", + "discount_total", + "discount_total_formatted", + "discount_total_usd", + "refunded", + "refunded_amount", + "refunded_amount_formatted", + "refunded_amount_usd", + "refunded_at", + "status", + "status_formatted", + "subtotal", + "subtotal_formatted", + "subtotal_usd", + "tax", + "tax_formatted", + "tax_inclusive", + "tax_usd", + "total", + "total_formatted", + "total_usd", + "updated_at" + ], + "properties": { + "billing_reason": { + "type": "string" + }, + "card_brand": { + "type": "string" + }, + "card_last_four": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "currency_rate": { + "type": "string" + }, + "discount_total": { + "type": "integer" + }, + "discount_total_formatted": { + "type": "string" + }, + "discount_total_usd": { + "type": "integer" + }, + "refunded": { + "type": "boolean" + }, + "refunded_amount": { + "type": "integer" + }, + "refunded_amount_formatted": { + "type": "string" + }, + "refunded_amount_usd": { + "type": "integer" + }, + "refunded_at": {}, + "status": { + "type": "string" + }, + "status_formatted": { + "type": "string" + }, + "subtotal": { + "type": "integer" + }, + "subtotal_formatted": { + "type": "string" + }, + "subtotal_usd": { + "type": "integer" + }, + "tax": { + "type": "integer" + }, + "tax_formatted": { + "type": "string" + }, + "tax_inclusive": { + "type": "boolean" + }, + "tax_usd": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_formatted": { + "type": "string" + }, + "total_usd": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, "responses.WebhookResponse": { "type": "object", "required": ["data", "message", "status"], diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index a4124b82..f5563f2a 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -102,9 +102,13 @@ definitions: type: object entities.Message: properties: - can_be_polled: - example: false - type: boolean + attachments: + example: + - https://example.com/image.jpg + - https://example.com/video.mp4 + items: + type: string + type: array contact: example: "+18005550100" type: string @@ -172,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 @@ -192,28 +197,17 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - can_be_polled + - attachments - contact - content - created_at - - delivered_at - encrypted - - expired_at - - failed_at - - failure_reason - id - - last_attempted_at - max_send_attempts - order_timestamp - owner - - received_at - - request_id - request_received_at - - scheduled_at - - scheduled_send_time - send_attempt_count - - send_time - - sent_at - sim - status - type @@ -304,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 @@ -314,12 +307,10 @@ definitions: type: string required: - created_at - - fcm_token - id - max_send_attempts - message_expiration_seconds - messages_per_minute - - missed_call_auto_reply - phone_number - sim - updated_at @@ -373,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: @@ -409,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 @@ -424,7 +450,6 @@ definitions: example: "2022-06-05T14:26:10.303278+03:00" type: string required: - - active_phone_id - api_key - created_at - email @@ -433,11 +458,8 @@ definitions: - notification_message_status_enabled - notification_newsletter_enabled - notification_webhook_enabled - - subscription_ends_at - subscription_id - subscription_name - - subscription_renews_at - - subscription_status - timezone - updated_at type: object @@ -522,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 @@ -551,7 +599,6 @@ definitions: type: array required: - content - - encrypted - from - to type: object @@ -601,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 @@ -614,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 @@ -636,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 @@ -658,8 +723,10 @@ definitions: send_at: description: SendAt is an optional parameter used to schedule a message to - be sent at a later time - example: "2022-06-05T14:26:09.527976+03:00" + be sent in the future. The time is considered to be in your profile's local + timezone and you can queue messages for up to 20 days (480 hours) in the + future. + example: "2025-12-19T16:39:57-08:00" type: string to: example: "+18005550100" @@ -765,6 +832,38 @@ definitions: - newsletter_enabled - webhook_enabled type: object + requests.UserPaymentInvoice: + properties: + address: + example: 221B Baker Street, London + type: string + city: + example: Los Angeles + type: string + country: + example: US + type: string + name: + example: Acme Corp + type: string + notes: + example: Thank you for your business! + type: string + state: + example: CA + type: string + zip_code: + example: "9800" + type: string + required: + - address + - city + - country + - name + - notes + - state + - zip_code + type: object requests.UserUpdate: properties: active_phone_id: @@ -1149,6 +1248,116 @@ definitions: - message - status type: object + responses.UserSubscriptionPaymentsResponse: + properties: + data: + items: + properties: + attributes: + properties: + billing_reason: + type: string + card_brand: + type: string + card_last_four: + type: string + created_at: + type: string + currency: + type: string + currency_rate: + type: string + discount_total: + type: integer + discount_total_formatted: + type: string + discount_total_usd: + type: integer + refunded: + type: boolean + refunded_amount: + type: integer + refunded_amount_formatted: + type: string + refunded_amount_usd: + type: integer + refunded_at: {} + status: + type: string + status_formatted: + type: string + subtotal: + type: integer + subtotal_formatted: + type: string + subtotal_usd: + type: integer + tax: + type: integer + tax_formatted: + type: string + tax_inclusive: + type: boolean + tax_usd: + type: integer + total: + type: integer + total_formatted: + type: string + total_usd: + type: integer + updated_at: + type: string + required: + - billing_reason + - card_brand + - card_last_four + - created_at + - currency + - currency_rate + - discount_total + - discount_total_formatted + - discount_total_usd + - refunded + - refunded_amount + - refunded_amount_formatted + - refunded_amount_usd + - refunded_at + - status + - status_formatted + - subtotal + - subtotal_formatted + - subtotal_usd + - tax + - tax_formatted + - tax_inclusive + - tax_usd + - total + - total_formatted + - total_usd + - updated_at + type: object + id: + type: string + type: + type: string + required: + - attributes + - id + - type + type: object + type: array + message: + example: Request handled successfully + type: string + status: + example: success + type: string + required: + - data + - message + - status + type: object responses.WebhookResponse: properties: data: @@ -1185,10 +1394,10 @@ host: api.httpsms.com info: contact: email: support@httpsms.com - name: HTTP SMS + name: support@httpsms.com description: - API to send SMS messages using android [SmsManager](https://developer.android.com/reference/android/telephony/SmsManager) - via HTTP + Use your Android phone to send and receive SMS messages via a simple + programmable API with end-to-end encryption. license: name: AGPL-3.0 url: https://raw.githubusercontent.com/NdoleStudio/http-sms-manager/main/LICENSE @@ -1280,8 +1489,16 @@ paths: /bulk-messages: post: consumes: - - application/json - description: Sends bulk SMS messages to multiple users from a CSV file. + - multipart/form-data + description: + Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) + or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx). + parameters: + - description: The Excel or CSV file containing the messages to be sent. + in: formData + name: document + required: true + type: file produces: - application/json responses: @@ -1643,37 +1860,6 @@ paths: summary: Sends a 3CX SMS message tags: - 3CXIntegration - /lemonsqueezy/event: - post: - consumes: - - application/json - description: Publish a lemonsqueezy event to the registered listeners - produces: - - application/json - responses: - "204": - description: No Content - schema: - $ref: "#/definitions/responses.NoContent" - "400": - description: Bad Request - schema: - $ref: "#/definitions/responses.BadRequest" - "401": - description: Unauthorized - schema: - $ref: "#/definitions/responses.Unauthorized" - "422": - description: Unprocessable Entity - schema: - $ref: "#/definitions/responses.UnprocessableEntity" - "500": - description: Internal Server Error - schema: - $ref: "#/definitions/responses.InternalServerError" - summary: Consume a lemonsqueezy event - tags: - - Lemonsqueezy /message-threads: get: consumes: @@ -1931,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: @@ -2213,9 +2442,9 @@ paths: post: consumes: - application/json - description: Add a new SMS message to be sent by the android phone + description: Add a new SMS message to be sent by your Android phone parameters: - - description: PostSend message request payload + - description: Send message request payload in: body name: payload required: true @@ -2246,7 +2475,7 @@ paths: $ref: "#/definitions/responses.InternalServerError" security: - ApiKeyAuth: [] - summary: Send a new SMS message + summary: Send an SMS message tags: - Messages /phone-api-keys: @@ -2859,6 +3088,131 @@ paths: summary: Currently authenticated user subscription update URL tags: - Users + /users/subscription/invoices/{subscriptionInvoiceID}: + post: + consumes: + - application/json + description: + Generates a new invoice PDF file for the given subscription payment + with given parameters. + parameters: + - description: Generate subscription payment invoice parameters + in: body + name: payload + 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: + "200": + description: OK + schema: + type: file + "400": + description: Bad Request + schema: + $ref: "#/definitions/responses.BadRequest" + "401": + description: Unauthorized + schema: + $ref: "#/definitions/responses.Unauthorized" + "422": + description: Unprocessable Entity + schema: + $ref: "#/definitions/responses.UnprocessableEntity" + "500": + description: Internal Server Error + schema: + $ref: "#/definitions/responses.InternalServerError" + security: + - ApiKeyAuth: [] + summary: Generate a subscription payment invoice + tags: + - Users + /users/subscription/payments: + get: + consumes: + - application/json + description: + Subscription payments are generated throughout the lifecycle of + a subscription, typically there is one at the time of purchase and then one + for each renewal. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: "#/definitions/responses.UserSubscriptionPaymentsResponse" + "400": + description: Bad Request + schema: + $ref: "#/definitions/responses.BadRequest" + "401": + description: Unauthorized + schema: + $ref: "#/definitions/responses.Unauthorized" + "422": + description: Unprocessable Entity + schema: + $ref: "#/definitions/responses.UnprocessableEntity" + "500": + description: Internal Server Error + schema: + $ref: "#/definitions/responses.InternalServerError" + security: + - ApiKeyAuth: [] + summary: 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 38db6836..3c361930 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,186 +1,205 @@ module github.com/NdoleStudio/httpsms -go 1.24.2 - -toolchain go1.24.3 +go 1.25.0 require ( - cloud.google.com/go/cloudtasks v1.13.6 + 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.53.0 - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.29.0 - github.com/NdoleStudio/go-otelroundtripper v0.0.12 - github.com/NdoleStudio/lemonsqueezy-go v1.2.4 - github.com/avast/retry-go v3.0.0+incompatible - github.com/carlmjohnson/requests v0.24.3 - github.com/cloudevents/sdk-go/v2 v2.16.1 - github.com/cockroachdb/cockroach-go/v2 v2.4.1 + 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.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 + github.com/carlmjohnson/requests v0.25.1 + github.com/cloudevents/sdk-go/v2 v2.16.2 + github.com/cockroachdb/cockroach-go/v2 v2.4.3 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc - github.com/dgraph-io/ristretto v1.0.0 + github.com/dgraph-io/ristretto/v2 v2.4.0 github.com/dustin/go-humanize v1.0.1 + github.com/go-hermes/hermes/v2 v2.6.2 github.com/gofiber/contrib/otelfiber v1.0.10 - github.com/gofiber/fiber/v2 v2.52.9 + github.com/gofiber/fiber/v2 v2.52.12 github.com/gofiber/swagger v1.1.1 - github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/hirosassa/zerodriver v0.1.4 - github.com/jaswdr/faker/v2 v2.6.1 + github.com/jaswdr/faker/v2 v2.9.1 github.com/jinzhu/now v1.1.5 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.10.9 - github.com/matcornic/hermes v1.3.0 - github.com/nyaruka/phonenumbers v1.6.4 + 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.11.0 - github.com/redis/go-redis/v9 v9.11.0 - github.com/rs/zerolog v1.34.0 - github.com/stretchr/testify v1.10.0 + 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.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/uptrace/uptrace-go v1.37.0 - github.com/xuri/excelize/v2 v2.9.1 - go.opentelemetry.io/otel v1.37.0 - go.opentelemetry.io/otel/metric v1.37.0 - go.opentelemetry.io/otel/sdk v1.37.0 - go.opentelemetry.io/otel/sdk/metric v1.37.0 - go.opentelemetry.io/otel/trace v1.37.0 - google.golang.org/api v0.244.0 - google.golang.org/protobuf v1.36.6 + github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc + github.com/uptrace/uptrace-go v1.41.1 + github.com/xuri/excelize/v2 v2.10.1 + 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/gorm v1.30.1 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 gorm.io/plugin/opentelemetry v0.1.16 ) require ( - cel.dev/expr v0.24.0 // indirect - cloud.google.com/go v0.121.3 // indirect - cloud.google.com/go/auth v0.16.3 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + 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.8 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/cast v1.10.0 // 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.19.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.7.0 // indirect - cloud.google.com/go/firestore v1.18.0 // indirect - cloud.google.com/go/iam v1.5.2 // indirect - cloud.google.com/go/longrunning v0.6.7 // indirect - cloud.google.com/go/monitoring v1.24.2 // indirect - cloud.google.com/go/storage v1.55.0 // indirect - cloud.google.com/go/trace v1.11.6 // 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.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.1.0 // indirect - github.com/ClickHouse/ch-go v0.66.1 // indirect - github.com/ClickHouse/clickhouse-go/v2 v2.37.2 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/ClickHouse/ch-go v0.71.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/Masterminds/semver v1.5.0 // indirect - github.com/Masterminds/sprig v2.22.0+incompatible // indirect - github.com/PuerkitoBio/goquery v1.10.3 // 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/cenkalti/backoff/v5 v5.0.2 // 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/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // 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.32.4 // indirect - github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.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.1 // 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.21.1 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/swag v0.23.1 // 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.6 // indirect - github.com/googleapis/gax-go/v2 v2.15.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.27.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.7.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect - github.com/imdario/mergo v0.3.16 // 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.7.5 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect github.com/jinzhu/inflection v1.0.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/mailru/easyjson v0.9.0 // 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.16 // 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 v0.0.5 // indirect - github.com/paulmach/orb v0.11.1 // indirect - github.com/pierrec/lz4/v4 v4.1.22 // 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.11.0 // indirect - github.com/richardlehane/mscfb v1.0.4 // indirect - github.com/richardlehane/msoleps v1.0.4 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/segmentio/asm v1.2.0 // indirect + github.com/redis/go-redis/extra/rediscmd/v9 v9.18.0 // indirect + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/swaggo/files/v2 v2.0.2 // indirect - github.com/tiendc/go-deepcopy v1.6.1 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.63.0 // indirect + github.com/valyala/fasthttp v1.69.0 // indirect github.com/vanng822/css v1.0.1 // indirect - github.com/vanng822/go-premailer v1.25.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.1 // indirect - github.com/zeebo/errs v1.4.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib v1.37.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect - go.opentelemetry.io/contrib/instrumentation/runtime v0.62.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.13.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 // indirect - go.opentelemetry.io/otel/log v0.13.0 // indirect - go.opentelemetry.io/otel/sdk/log v0.13.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.0 // 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.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.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.34.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // 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-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect - google.golang.org/grpc v1.74.2 // 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 d23dae81..60df8733 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,151 +1,181 @@ bou.ke/monkey v1.0.2 h1:kWcnsrCNUatbxncxR/ThdYqbytgOIArtYWqcQLQzKLI= bou.ke/monkey v1.0.2/go.mod h1:OqickVX3tNx6t33n1xvtTtu85YN5s6cKwVug+oHMaIA= -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= -cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo= -cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc= -cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= -cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= +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.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.6 h1:Fwan19UiNoFD+3KY0MnNHE5DyixOxNzS1mZ4ChOdpy0= -cloud.google.com/go/cloudtasks v1.13.6/go.mod h1:/IDaQqGKMixD+ayM43CfsvWF2k36GeomEuy9gL4gLmU= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= -cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s= -cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU= -cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= -cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= -cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= -cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= -cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= -cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= -cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= -cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= -cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0= -cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= -cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= -cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= +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.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.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.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= -github.com/ClickHouse/ch-go v0.66.1 h1:LQHFslfVYZsISOY0dnOYOXGkOUvpv376CCm8g7W74A4= -github.com/ClickHouse/ch-go v0.66.1/go.mod h1:NEYcg3aOFv2EmTJfo4m2WF7sHB/YFbLUuIWv9iq76xY= -github.com/ClickHouse/clickhouse-go/v2 v2.37.2 h1:wRLNKoynvHQEN4znnVHNLaYnrqVc9sGJmGYg+GGCfto= -github.com/ClickHouse/clickhouse-go/v2 v2.37.2/go.mod h1:pH2zrBGp5Y438DMwAxXMm1neSXPPjSI7tD4MURVULw8= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.29.0 h1:YVtMlmfRUTaWs3+1acwMBp7rBUo6zrxl6Kn13/R9YW4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.29.0/go.mod h1:rKOFVIPbNs2wZeh7ZeQ0D9p/XLgbNiTr5m7x6KuAshk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= +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.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= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.31.0 h1:xQMhkBXPOKe/GzC6TctwlK2aNF+9k5VwFgdE83rBK2Y= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.31.0/go.mod h1:VLoD5cAsRQXsAFXpOZrrTGzbuMsntlspIZno4xor5Zg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= -github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= -github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= -github.com/NdoleStudio/go-otelroundtripper v0.0.12 h1:UAU7IcG8CR9GiZlgJR4yQj2zrnOokDYZCIUdDFHOTp4= -github.com/NdoleStudio/go-otelroundtripper v0.0.12/go.mod h1:nnY1D4NCtCXNi3f35hDw+OCOigh7DsKHkxdwuC9RzeM= -github.com/NdoleStudio/lemonsqueezy-go v1.2.4 h1:BhWlCUH+DIPfSn4g/V7f2nFkMCQuzno9DXKZ7YDrXXA= -github.com/NdoleStudio/lemonsqueezy-go v1.2.4/go.mod h1:2uZlWgn9sbNxOx3JQWLlPrDOC6NT/wmSTOgL3U/fMMw= -github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= -github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +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.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.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/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= -github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k= +github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/carlmjohnson/requests v0.24.3 h1:LYcM/jVIVPkioigMjEAnBACXl2vb42TVqiC8EYNoaXQ= -github.com/carlmjohnson/requests v0.24.3/go.mod h1:duYA/jDnyZ6f3xbcF5PpZ9N8clgopubP2nK5i6MVMhU= -github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= -github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/carlmjohnson/requests v0.25.1 h1:17zNRLecxtAjhtdEIV+F+wrYfe+AGZUjWJtpndcOUYA= +github.com/carlmjohnson/requests v0.25.1/go.mod h1:z3UEf8IE4sZxZ78spW6/tLdqBkfCu1Fn4RaYMnZ8SRM= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +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/cloudevents/sdk-go/v2 v2.16.1 h1:G91iUdqvl88BZ1GYYr9vScTj5zzXSyEuqbfE63gbu9Q= -github.com/cloudevents/sdk-go/v2 v2.16.1/go.mod h1:v/kVOaWjNfbvc6tkhhlkhvLapj8Aa8kvXiH5GiOHCKI= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= -github.com/cockroachdb/cockroach-go/v2 v2.4.1 h1:ACVT/zXsuK6waRPVYtDQpsM8pPA7IA/3fkgA02RR/Gw= -github.com/cockroachdb/cockroach-go/v2 v2.4.1/go.mod h1:9U179XbCx4qFWtNhc7BiWLPfuyMVQ7qdAhfrwLz1vH0= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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= +github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/cockroachdb/cockroach-go/v2 v2.4.3 h1:LJO3K3jC5WXvMePRQSJE1NsIGoFGcEx1LW83W6RAlhw= +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/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= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84= -github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc= -github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= -github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU= +github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +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.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= -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= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= -github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= -github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +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.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.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= -github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= -github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= -github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= -github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +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.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.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= -github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= +github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= 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 v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +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= @@ -157,40 +187,38 @@ 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.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= -github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +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.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 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.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.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= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= -github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/inbucket/html2text v1.0.0 h1:N5kza++4uBBDJ2Z3KUnTRyPNoBcW+YfOgNiNmNB+sgs= +github.com/inbucket/html2text v1.0.0/go.mod h1:5TrhXQKGU+LXurODaSm55Y9eXoPBRnYiOz4x2XfUoJU= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 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.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= -github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +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.6.1 h1:TlGSt2WYc9ut4IfCd1hk4luXQRjImC+Zza0KS+t0aR8= -github.com/jaswdr/faker/v2 v2.6.1/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68= -github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= -github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/jaswdr/faker/v2 v2.9.1 h1:J0Rjqb2/FquZnoZplzkGVL5LmhNkeIpvsSMoJKzn+8E= +github.com/jaswdr/faker/v2 v2.9.1/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -199,42 +227,28 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +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.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.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/matcornic/hermes v1.3.0 h1:k6rih7zpUgfIF/57F3WeBi9n68XkvhC/z8eQTRIsQqc= -github.com/matcornic/hermes v1.3.0/go.mod h1:X3MXWWBHjKSfgQl0xjv+NQTAGWSiNr/fZTlhAEQJ63Q= -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.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= -github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +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= @@ -244,20 +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.4 h1:GFAa844VqRKJvO7oboosM1q3gFVgYvyNe0O6CCbg33A= -github.com/nyaruka/phonenumbers v1.6.4/go.mod h1:7gjs+Lchqm49adhAKB5cdcng5ZXgt6x7Jgvi0ZorUtU= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +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.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.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= -github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= -github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= -github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= -github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +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= @@ -267,161 +285,151 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pusher/pusher-http-go/v5 v5.1.1 h1:ZLUGdLA8yXMvByafIkS47nvuXOHrYmlh4bsQvuZnYVQ= github.com/pusher/pusher-http-go/v5 v5.1.1/go.mod h1:Ibji4SGoUDtOy7CVRhCiEpgy+n5Xv6hSL/QqYOhmWW8= -github.com/redis/go-redis/extra/rediscmd/v9 v9.11.0 h1:vP5CH2rJ3L4yk3o8FdXqiPL1lGl5APjHcxk5/OT6H0Q= -github.com/redis/go-redis/extra/rediscmd/v9 v9.11.0/go.mod h1:/2yj0RD4xjZQ7wOg9u7gVoBM0IgMGrHunAql1hr1NDg= -github.com/redis/go-redis/extra/redisotel/v9 v9.11.0 h1:dMNmusapfQefntfUqAYAvaVJMrJCdKUaQoPSZtd99WU= -github.com/redis/go-redis/extra/redisotel/v9 v9.11.0/go.mod h1:Yy5oaeVwWj7KMu6Mga/i4imlXFvgitQWN5HFiT5JqoE= -github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= -github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= -github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= -github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= -github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/redis/go-redis/extra/rediscmd/v9 v9.18.0 h1:QY4nmPHLFAJjtT5O4OMUEOxP8WVaRNOFpcbmxT2NLZU= +github.com/redis/go-redis/extra/rediscmd/v9 v9.18.0/go.mod h1:WH8cY/0fT41Bsf341qzo8v4nx0GCE8FykAA23IVbVmo= +github.com/redis/go-redis/extra/redisotel/v9 v9.18.0 h1:2dKdoEYBJ0CZCLPiCdvvc7luz3DPwY6hKdzjL6m1eHE= +github.com/redis/go-redis/extra/redisotel/v9 v9.18.0/go.mod h1:WzkrVG9ro9BwCQD0eJOWn6AGL4Z1CleGflM45w1hu10= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +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/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= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= -github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= 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.6.1 h1:uVRTItFeNHkMcLueHS7OCsxgxT9P8MzGB/taUa2Y4Tk= -github.com/tiendc/go-deepcopy v1.6.1/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I= -github.com/uptrace/uptrace-go v1.37.0 h1:9ohbWB0qZEfcPLFbfqAAt5wz2rcBmL60/QqkOkvqYOs= -github.com/uptrace/uptrace-go v1.37.0/go.mod h1:3xAdXLVyEoqvRwuj3D/n1s9bLl7Ok+OnNaW889fvtDQ= +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.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.63.0 h1:DisIL8OjB7ul2d7cBaMRcKTQDYnrGy56R4FCiuDP0Ns= -github.com/valyala/fasthttp v1.63.0/go.mod h1:REc4IeW+cAEyLrRPa5A81MIjvz0QE1laoTX2EaPHKJM= +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.25.0 h1:hGHKfroCXrCDTyGVR8o4HCON5/HWvc7C1uocS+VnaZs= -github.com/vanng822/go-premailer v1.25.0/go.mod h1:8WJKIPZtegxqSOA8+eDFx7QNesKmMYfGEIodLTJqrtM= -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.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw= -github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s= -github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q= -github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +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/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= -go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib v1.37.0 h1:D6KBfpW31z7ty0qbheujzwJDsqubVGYoaBJojh5vYnY= -go.opentelemetry.io/contrib v1.37.0/go.mod h1:V0PijCkYR5XurE5ytnNJuqWMXPW60jJTPXOiKj6nvhI= -go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA= -go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= -go.opentelemetry.io/contrib/instrumentation/runtime v0.62.0 h1:ZIt0ya9/y4WyRIzfLC8hQRRsWg0J9M9GyaGtIMiElZI= -go.opentelemetry.io/contrib/instrumentation/runtime v0.62.0/go.mod h1:F1aJ9VuiKWOlWwKdTYDUp1aoS0HzQxg38/VLxKmhm5U= +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.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.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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.13.0 h1:zUfYw8cscHHLwaY8Xz3fiJu+R59xBnkgq2Zr1lwmK/0= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.13.0/go.mod h1:514JLMCcFLQFS8cnTepOk6I09cKWJ5nGHBxHrMJ8Yfg= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.37.0 h1:9PgnL3QNlj10uGxExowIDIZu66aVBwWhXmbOp1pa6RA= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.37.0/go.mod h1:0ineDcLELf6JmKfuo0wvvhAVMuxWFYvkTin2iV4ydPQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 h1:SNhVp/9q4Go/XHBkQ1/d5u9P/U+L1yaGPoi0x+mStaI= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0/go.mod h1:tx8OOlGH6R4kLV67YaYO44GFXloEjGPZuMjEkaaqIp4= -go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls= -go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +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.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/log v0.13.0 h1:I3CGUszjM926OphK8ZdzF+kLqFvfRY/IIoFq/TjwfaQ= -go.opentelemetry.io/otel/sdk/log v0.13.0/go.mod h1:lOrQyCCXmpZdN7NchXb6DOZZa1N5G1R2tm5GMMTpDBw= -go.opentelemetry.io/otel/sdk/log/logtest v0.13.0 h1:9yio6AFZ3QD9j9oqshV1Ibm9gPLlHNxurno5BreMtIA= -go.opentelemetry.io/otel/sdk/log/logtest v0.13.0/go.mod h1:QOGiAJHl+fob8Nu85ifXfuQYmJTFAvcrxL6w5/tu168= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= -go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +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= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +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= @@ -429,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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +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= @@ -461,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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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= @@ -474,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= @@ -483,43 +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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +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= -google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE= -google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8= +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-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +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= @@ -533,9 +530,9 @@ gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/driver/sqlite v1.5.0 h1:zKYbzRCpBrT1bNijRnxLDJWPjVfImGEn0lSnUY5gZ+c= -gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I= -gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= -gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc= gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y= diff --git a/api/main.go b/api/main.go index 5b7539c9..b85c9d66 100644 --- a/api/main.go +++ b/api/main.go @@ -7,6 +7,7 @@ import ( "github.com/NdoleStudio/httpsms/docs" "github.com/NdoleStudio/httpsms/pkg/di" + _ "github.com/tursodatabase/libsql-client-go/libsql" ) // Version is injected at runtime diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 2ceadc25..33d27b01 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -7,21 +7,25 @@ import ( "net/http" "os" "strconv" + "strings" "time" + plunk "github.com/NdoleStudio/plunk-go" "github.com/pusher/pusher-http-go/v5" + "gorm.io/driver/sqlite" "github.com/NdoleStudio/httpsms/docs" otelMetric "go.opentelemetry.io/otel/metric" - "github.com/dgraph-io/ristretto" + "github.com/dgraph-io/ristretto/v2" "github.com/gofiber/contrib/otelfiber" "gorm.io/plugin/opentelemetry/tracing" "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" @@ -77,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 @@ -115,6 +120,7 @@ func NewContainer(projectID string, version string) (container *Container) { container.RegisterMessageListeners() container.RegisterMessageRoutes() + container.RegisterAttachmentRoutes() container.RegisterBulkMessageRoutes() container.RegisterMessageThreadRoutes() @@ -227,6 +233,16 @@ func (container *Container) GormLogger() gormLogger.Interface { ) } +func (container *Container) connect(dsn string, config *gorm.Config) (db *gorm.DB, err error) { + if strings.HasPrefix(dsn, "libsql://") { + return gorm.Open(sqlite.New(sqlite.Config{ + DriverName: "libsql", + DSN: dsn, + }), config) + } + return gorm.Open(postgres.Open(dsn), config) +} + // DedicatedDB creates an instance of gorm.DB if it has not been created already func (container *Container) DedicatedDB() (db *gorm.DB) { container.logger.Debug(fmt.Sprintf("creating %T", db)) @@ -241,7 +257,7 @@ func (container *Container) DedicatedDB() (db *gorm.DB) { config = &gorm.Config{Logger: container.GormLogger()} } - db, err := gorm.Open(postgres.Open(os.Getenv("DATABASE_URL_DEDICATED")), config) + db, err := container.connect(os.Getenv("DATABASE_URL_DEDICATED"), config) if err != nil { container.logger.Fatal(err) } @@ -251,8 +267,9 @@ func (container *Container) DedicatedDB() (db *gorm.DB) { container.logger.Fatal(stacktrace.Propagate(err, "cannot get sql.DB from GORM")) } - sqlDB.SetMaxOpenConns(2) - sqlDB.SetConnMaxLifetime(time.Hour) + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(0) + sqlDB.SetConnMaxLifetime(10 * time.Second) if err = db.Use(tracing.NewPlugin()); err != nil { container.logger.Fatal(stacktrace.Propagate(err, "cannot use GORM tracing plugin")) @@ -319,13 +336,21 @@ func (container *Container) DB() (db *gorm.DB) { container.logger.Fatal(stacktrace.Propagate(err, "cannot use GORM tracing plugin")) } + if os.Getenv("DATABASE_MIGRATION_SKIP") != "" { + container.logger.Debug(fmt.Sprintf("skipping migrations for [%T]", db)) + return container.db + } + container.logger.Debug(fmt.Sprintf("Running migrations for %T", db)) // This prevents a bug in the Gorm AutoMigrate where it tries to delete this no existent constraints - db.Exec(` + // This is only applicable to PROD on cockroachDB + if os.Getenv("DATABASE_MIGRATION_CONSTRAINT_FIX") == "1" { + db.Exec(` ALTER TABLE users ADD CONSTRAINT IF NOT EXISTS uni_users_api_key CHECK (api_key IS NOT NULL); ALTER TABLE phone_api_keys ADD CONSTRAINT IF NOT EXISTS uni_phone_api_keys_api_key CHECK (api_key IS NOT NULL); ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (server_id IS NOT NULL);`) + } if err = db.AutoMigrate(&entities.Message{}); err != nil { container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Message{}))) @@ -373,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)) @@ -516,6 +541,7 @@ func (container *Container) MessageHandlerValidator() (validator *validators.Mes container.Tracer(), container.PhoneService(), container.TurnstileTokenValidator(), + container.Cache(), ) } @@ -538,6 +564,7 @@ func (container *Container) BulkMessageHandlerValidator() (validator *validators container.Tracer(), container.PhoneService(), container.UserService(), + container.Cache(), ) } @@ -647,6 +674,7 @@ func (container *Container) UserHandlerValidator() (validator *validators.UserHa return validators.NewUserHandlerValidator( container.Logger(), container.Tracer(), + container.UserService(), ) } @@ -721,6 +749,7 @@ func (container *Container) PhoneRepository() (repository repositories.PhoneRepo container.Logger(), container.Tracer(), container.DB(), + container.PhoneRistrettoCache(), ) } @@ -828,7 +857,10 @@ func (container *Container) WebhookService() (service *services.WebhookService) return services.NewWebhookService( container.Logger(), container.Tracer(), - container.HTTPClient("webhook"), + &http.Client{ + Timeout: 6 * time.Second, + Transport: container.HTTPRoundTripperWithoutRetry("webhook"), + }, container.WebhookRepository(), container.EventDispatcher(), ) @@ -865,6 +897,16 @@ func (container *Container) HTTPRoundTripper(name string) http.RoundTripper { ) } +// HTTPRoundTripperWithoutRetry creates an open telemetry http.RoundTripper without retry +func (container *Container) HTTPRoundTripperWithoutRetry(name string) http.RoundTripper { + container.logger.Debug(fmt.Sprintf("Debug: initializing %s %T", name, http.DefaultTransport)) + return otelroundtripper.New( + otelroundtripper.WithName(name), + otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)), + otelroundtripper.WithAttributes(container.OtelResources(container.version, container.projectID).Attributes()...), + ) +} + // OtelResources generates default open telemetry resources func (container *Container) OtelResources(version string, namespace string) *resource.Resource { return resource.NewWithAttributes( @@ -903,7 +945,7 @@ func (container *Container) MarketingService() (service *services.MarketingServi container.Logger(), container.Tracer(), container.FirebaseAuthClient(), - os.Getenv("BREVO_API_KEY"), + container.PlunkClient(), ) } @@ -916,10 +958,10 @@ func (container *Container) UserService() (service *services.UserService) { container.UserRepository(), container.Mailer(), container.UserEmailFactory(), - container.MarketingService(), container.LemonsqueezyClient(), container.EventDispatcher(), container.FirebaseAuthClient(), + container.HTTPClient("lemonsqueezy"), ) } @@ -1168,6 +1210,16 @@ func (container *Container) DiscordClient() (client *discord.Client) { ) } +// PlunkClient creates a new instance of plunk.Client +func (container *Container) PlunkClient() (client *plunk.Client) { + container.logger.Debug(fmt.Sprintf("creating %T", client)) + return plunk.New( + plunk.WithHTTPClient(container.HTTPClient("plunk")), + plunk.WithSecretKey(os.Getenv("PLUNK_SECRET_KEY")), + plunk.WithPublicKey(os.Getenv("PLUNK_PUBLIC_KEY")), + ) +} + // RegisterLemonsqueezyRoutes registers routes for the /lemonsqueezy prefix func (container *Container) RegisterLemonsqueezyRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.LemonsqueezyHandler{})) @@ -1293,6 +1345,12 @@ func (container *Container) RegisterDiscordListeners() { // RegisterMarketingListeners registers event listeners for listeners.MarketingListener func (container *Container) RegisterMarketingListeners() { container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.MarketingListener{})) + + if os.Getenv("PLUNK_SECRET_KEY") == "" { + container.logger.Debug("skipping marketing listeners because the PLUNK_SECRET_KEY env variable is not set") + return + } + _, routes := listeners.NewMarketingListener( container.Logger(), container.Tracer(), @@ -1375,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)) @@ -1405,8 +1517,8 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi // RegisterMessageRoutes registers routes for the /messages prefix func (container *Container) RegisterMessageRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.MessageHandler{})) - container.MessageHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) container.MessageHandler().RegisterPhoneAPIKeyRoutes(container.App(), container.PhoneAPIKeyMiddleware(), container.AuthenticatedMiddleware()) + container.MessageHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) } // RegisterBulkMessageRoutes registers routes for the /bulk-messages prefix @@ -1466,6 +1578,7 @@ func (container *Container) RegisterSwaggerRoutes() { Title: docs.SwaggerInfo.Title, CustomScript: ` document.addEventListener("DOMContentLoaded", function(event) { + document.body.style.margin = '0'; var links = document.querySelectorAll("link[rel~='icon']"); links.forEach(function (link) { link.href = 'https://httpsms.com/favicon.ico'; @@ -1495,6 +1608,20 @@ func (container *Container) UserRepository() repositories.UserRepository { ) } +// PhoneRistrettoCache creates an in-memory *ristretto.Cache[string, *entities.Phone] +func (container *Container) PhoneRistrettoCache() (cache *ristretto.Cache[string, *entities.Phone]) { + container.logger.Debug(fmt.Sprintf("creating %T", cache)) + ristrettoCache, err := ristretto.NewCache[string, *entities.Phone](&ristretto.Config[string, *entities.Phone]{ + MaxCost: 5000, + NumCounters: 5000 * 10, + BufferItems: 64, + }) + if err != nil { + container.logger.Fatal(stacktrace.Propagate(err, "cannot create user ristretto cache")) + } + return ristrettoCache +} + // UserRistrettoCache creates an in-memory *ristretto.Cache[string, entities.AuthContext] func (container *Container) UserRistrettoCache() (cache *ristretto.Cache[string, entities.AuthContext]) { container.logger.Debug(fmt.Sprintf("creating %T", cache)) diff --git a/api/pkg/emails/hermes_mailer.go b/api/pkg/emails/hermes_mailer.go index 0afce49d..7efe0f8a 100644 --- a/api/pkg/emails/hermes_mailer.go +++ b/api/pkg/emails/hermes_mailer.go @@ -5,7 +5,7 @@ import ( "strconv" "time" - "github.com/matcornic/hermes" + "github.com/go-hermes/hermes/v2" ) // HermesGeneratorConfig contains details for the generator diff --git a/api/pkg/emails/hermes_notification_email_factory.go b/api/pkg/emails/hermes_notification_email_factory.go index 0447997d..7c4b7bc5 100644 --- a/api/pkg/emails/hermes_notification_email_factory.go +++ b/api/pkg/emails/hermes_notification_email_factory.go @@ -7,7 +7,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/entities" - "github.com/matcornic/hermes" + "github.com/go-hermes/hermes/v2" "github.com/palantir/stacktrace" ) @@ -33,11 +33,11 @@ func (factory *hermesNotificationEmailFactory) DiscordSendFailed(user *entities. fmt.Sprintf("We ran into an error while fowarding an incoming SMS to your discord server at %s", user.UserTimeString(time.Now())), }, Dictionary: []hermes.Entry{ - {"Discord Channel ID", payload.DiscordChannelID}, - {"Event Name", payload.EventType}, - {"Phone Number", factory.formatPhoneNumber(payload.Owner)}, - {"HTTP Response Code", factory.formatHTTPResponseCode(payload.HTTPResponseStatusCode)}, - {"Error Message / HTTP Response", payload.ErrorMessage}, + {Key: "Discord Channel ID", Value: payload.DiscordChannelID}, + {Key: "Event Name", Value: payload.EventType}, + {Key: "Phone Number", Value: factory.formatPhoneNumber(payload.Owner)}, + {Key: "HTTP Response Code", Value: factory.formatHTTPResponseCode(payload.HTTPResponseStatusCode)}, + {Key: "Error Message / HTTP Response", Value: payload.ErrorMessage}, }, Actions: []hermes.Action{ { @@ -83,13 +83,13 @@ func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities. fmt.Sprintf("We ran into an error while fowarding a webhook event from httpSMS to your webserver at %s", user.UserTimeString(time.Now())), }, Dictionary: []hermes.Entry{ - {"Server URL", payload.WebhookURL}, - {"Event Name", payload.EventType}, - {"Event ID", payload.EventID}, - {"Phone Number", factory.formatPhoneNumber(payload.Owner)}, - {"HTTP Response Code", factory.formatHTTPResponseCode(payload.HTTPResponseStatusCode)}, - {"Error Message / HTTP Response", payload.ErrorMessage}, - {"Event Payload", payload.EventPayload}, + {Key: "Server URL", Value: payload.WebhookURL}, + {Key: "Event Name", Value: payload.EventType}, + {Key: "Event ID", Value: payload.EventID}, + {Key: "Phone Number", Value: factory.formatPhoneNumber(payload.Owner)}, + {Key: "HTTP Response Code", Value: factory.formatHTTPResponseCode(payload.HTTPResponseStatusCode)}, + {Key: "Error Message / HTTP Response", Value: payload.ErrorMessage}, + {Key: "Event Payload", Value: payload.EventPayload}, }, Actions: []hermes.Action{ { @@ -135,11 +135,11 @@ func (factory *hermesNotificationEmailFactory) MessageExpired(user *entities.Use fmt.Sprintf("The SMS message which you sent to %s has expired at %s and you will need to resend this message.", factory.formatPhoneNumber(payload.Contact), user.UserTimeString(time.Now())), }, Dictionary: []hermes.Entry{ - {"ID", payload.MessageID.String()}, - {"From", factory.formatPhoneNumber(payload.Owner)}, - {"To", factory.formatPhoneNumber(payload.Contact)}, - {"Message", payload.Content}, - {"Encrypted", factory.formatBool(payload.Encrypted)}, + {Key: "ID", Value: payload.MessageID.String()}, + {Key: "From", Value: factory.formatPhoneNumber(payload.Owner)}, + {Key: "To", Value: factory.formatPhoneNumber(payload.Contact)}, + {Key: "Message", Value: payload.Content}, + {Key: "Encrypted", Value: factory.formatBool(payload.Encrypted)}, }, Actions: []hermes.Action{ { @@ -185,12 +185,12 @@ func (factory *hermesNotificationEmailFactory) MessageFailed(user *entities.User fmt.Sprintf("The SMS message which you sent to %s has failed at %s and you will need to resend this message.", factory.formatPhoneNumber(payload.Contact), user.UserTimeString(time.Now())), }, Dictionary: []hermes.Entry{ - {"ID", payload.ID.String()}, - {"From", factory.formatPhoneNumber(payload.Owner)}, - {"To", factory.formatPhoneNumber(payload.Contact)}, - {"Message", payload.Content}, - {"Encrypted", factory.formatBool(payload.Encrypted)}, - {"Failure Reason", payload.ErrorMessage}, + {Key: "ID", Value: payload.ID.String()}, + {Key: "From", Value: factory.formatPhoneNumber(payload.Owner)}, + {Key: "To", Value: factory.formatPhoneNumber(payload.Contact)}, + {Key: "Message", Value: payload.Content}, + {Key: "Encrypted", Value: factory.formatBool(payload.Encrypted)}, + {Key: "Failure Reason", Value: payload.ErrorMessage}, }, Actions: []hermes.Action{ { diff --git a/api/pkg/emails/hermes_theme.go b/api/pkg/emails/hermes_theme.go index 56b49759..9d8cc471 100644 --- a/api/pkg/emails/hermes_theme.go +++ b/api/pkg/emails/hermes_theme.go @@ -1,10 +1,14 @@ package emails -import "github.com/matcornic/hermes" +import "github.com/go-hermes/hermes/v2" // hermesTheme is the theme by default type hermesTheme struct{} +func (dt *hermesTheme) Styles() hermes.StylesDefinition { + return hermes.Default{}.Styles() +} + func newHermesTheme() hermes.Theme { return &hermesTheme{} } diff --git a/api/pkg/emails/hermes_user_email_factory.go b/api/pkg/emails/hermes_user_email_factory.go index 3d50f6cc..9ec5754a 100644 --- a/api/pkg/emails/hermes_user_email_factory.go +++ b/api/pkg/emails/hermes_user_email_factory.go @@ -5,7 +5,7 @@ import ( "time" "github.com/NdoleStudio/httpsms/pkg/entities" - "github.com/matcornic/hermes" + "github.com/go-hermes/hermes/v2" "github.com/palantir/stacktrace" ) diff --git a/api/pkg/entities/event_listener_log.go b/api/pkg/entities/event_listener_log.go deleted file mode 100644 index 50f0662c..00000000 --- a/api/pkg/entities/event_listener_log.go +++ /dev/null @@ -1,18 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -// EventListenerLog stores the log of all the events handled -type EventListenerLog struct { - ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;"` - EventID string `json:"event_id" gorm:"index:idx_event_listener_log_event_id_handler"` - EventType string `json:"event_type"` - Handler string `json:"handler" gorm:"index:idx_event_listener_log_event_id_handler"` - Duration time.Duration `json:"duration"` - HandledAt time.Time `json:"handled_at"` - CreatedAt time.Time `json:"created_at"` -} diff --git a/api/pkg/entities/message.go b/api/pkg/entities/message.go index fcec05a4..52a9a221 100644 --- a/api/pkg/entities/message.go +++ b/api/pkg/entities/message.go @@ -4,6 +4,7 @@ import ( "time" "github.com/google/uuid" + "github.com/lib/pq" ) // MessageType is the type of message if it is incoming or outgoing @@ -83,15 +84,16 @@ func (s SIM) String() string { // Message represents a message sent between 2 phone numbers type Message struct { - ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` - RequestID *string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4"` - Owner string `json:"owner" example:"+18005550199"` - UserID UserID `json:"user_id" gorm:"index:idx_messages__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` - Contact string `json:"contact" example:"+18005550100"` - Content string `json:"content" example:"This is a sample text message"` - Encrypted bool `json:"encrypted" example:"false" gorm:"default:false"` - Type MessageType `json:"type" example:"mobile-terminated"` - Status MessageStatus `json:"status" example:"pending"` + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` + RequestID *string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"` + Owner string `json:"owner" example:"+18005550199"` + UserID UserID `json:"user_id" gorm:"index:idx_messages__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` + Contact string `json:"contact" example:"+18005550100"` + Content string `json:"content" example:"This is a sample text message"` + Attachments 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"` // 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 @@ -99,24 +101,24 @@ type Message struct { SIM SIM `json:"sim" example:"DEFAULT"` // SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message - SendDuration *int64 `json:"send_time" example:"133414"` + SendDuration *int64 `json:"send_time" example:"133414" validate:"optional"` RequestReceivedAt time.Time `json:"request_received_at" example:"2022-06-05T14:26:01.520828+03:00"` CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` OrderTimestamp time.Time `json:"order_timestamp" example:"2022-06-05T14:26:09.527976+03:00"` - LastAttemptedAt *time.Time `json:"last_attempted_at" example:"2022-06-05T14:26:09.527976+03:00"` - NotificationScheduledAt *time.Time `json:"scheduled_at" example:"2022-06-05T14:26:09.527976+03:00"` - SentAt *time.Time `json:"sent_at" example:"2022-06-05T14:26:09.527976+03:00"` - ScheduledSendTime *time.Time `json:"scheduled_send_time" example:"2022-06-05T14:26:09.527976+03:00"` - DeliveredAt *time.Time `json:"delivered_at" example:"2022-06-05T14:26:09.527976+03:00"` - ExpiredAt *time.Time `json:"expired_at" example:"2022-06-05T14:26:09.527976+03:00"` - FailedAt *time.Time `json:"failed_at" example:"2022-06-05T14:26:09.527976+03:00"` - CanBePolled bool `json:"can_be_polled" example:"false"` + LastAttemptedAt *time.Time `json:"last_attempted_at" example:"2022-06-05T14:26:09.527976+03:00" validate:"optional"` + NotificationScheduledAt *time.Time `json:"scheduled_at" example:"2022-06-05T14:26:09.527976+03:00" validate:"optional"` + SentAt *time.Time `json:"sent_at" example:"2022-06-05T14:26:09.527976+03:00" validate:"optional"` + ScheduledSendTime *time.Time `json:"scheduled_send_time" example:"2022-06-05T14:26:09.527976+03:00" validate:"optional"` + DeliveredAt *time.Time `json:"delivered_at" example:"2022-06-05T14:26:09.527976+03:00" validate:"optional"` + ExpiredAt *time.Time `json:"expired_at" example:"2022-06-05T14:26:09.527976+03:00" validate:"optional"` + FailedAt *time.Time `json:"failed_at" example:"2022-06-05T14:26:09.527976+03:00" validate:"optional"` + CanBePolled bool `json:"-" example:"false" swaggerignore:"true"` SendAttemptCount uint `json:"send_attempt_count" example:"0"` MaxSendAttempts uint `json:"max_send_attempts" example:"1"` - ReceivedAt *time.Time `json:"received_at" example:"2022-06-05T14:26:09.527976+03:00"` - FailureReason *string `json:"failure_reason" example:"UNKNOWN"` + ReceivedAt *time.Time `json:"received_at" example:"2022-06-05T14:26:09.527976+03:00" validate:"optional"` + FailureReason *string `json:"failure_reason" example:"UNKNOWN" validate:"optional"` } // IsSending determines if a message is being sent diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 7e6d9b26..83521759 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -10,7 +10,7 @@ import ( type Phone struct { ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` - FcmToken *string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` + FcmToken *string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." validate:"optional"` PhoneNumber string `json:"phone_number" example:"+18005550199"` MessagesPerMinute uint `json:"messages_per_minute" example:"1"` SIM SIM `json:"sim" gorm:"default:SIM1"` @@ -20,7 +20,7 @@ type Phone struct { // MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired. MessageExpirationSeconds uint `json:"message_expiration_seconds"` - MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"This phone cannot receive calls. Please send an SMS instead."` + MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"This phone cannot receive calls. Please send an SMS instead." validate:"optional"` CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` diff --git a/api/pkg/entities/user.go b/api/pkg/entities/user.go index 0d4cbbe7..f26a8135 100644 --- a/api/pkg/entities/user.go +++ b/api/pkg/entities/user.go @@ -76,12 +76,12 @@ type User struct { Email string `json:"email" example:"name@email.com"` APIKey string `json:"api_key" gorm:"uniqueIndex:idx_users_api_key;NOT NULL" example:"x-api-key"` Timezone string `json:"timezone" example:"Europe/Helsinki" gorm:"default:Africa/Accra"` - ActivePhoneID *uuid.UUID `json:"active_phone_id" gorm:"type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` + ActivePhoneID *uuid.UUID `json:"active_phone_id" gorm:"type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb" validate:"optional"` SubscriptionName SubscriptionName `json:"subscription_name" example:"free"` SubscriptionID *string `json:"subscription_id" example:"8f9c71b8-b84e-4417-8408-a62274f65a08"` - SubscriptionStatus *string `json:"subscription_status" example:"on_trial"` - SubscriptionRenewsAt *time.Time `json:"subscription_renews_at" example:"2022-06-05T14:26:02.302718+03:00"` - SubscriptionEndsAt *time.Time `json:"subscription_ends_at" example:"2022-06-05T14:26:02.302718+03:00"` + SubscriptionStatus *string `json:"subscription_status" example:"on_trial" validate:"optional"` + SubscriptionRenewsAt *time.Time `json:"subscription_renews_at" example:"2022-06-05T14:26:02.302718+03:00" validate:"optional"` + SubscriptionEndsAt *time.Time `json:"subscription_ends_at" example:"2022-06-05T14:26:02.302718+03:00" validate:"optional"` NotificationMessageStatusEnabled bool `json:"notification_message_status_enabled" gorm:"default:true" example:"true"` NotificationWebhookEnabled bool `json:"notification_webhook_enabled" gorm:"default:true" example:"true"` NotificationHeartbeatEnabled bool `json:"notification_heartbeat_enabled" gorm:"default:true" example:"true"` diff --git a/api/pkg/events/message_api_sent_event.go b/api/pkg/events/message_api_sent_event.go index 7abea843..60a418b2 100644 --- a/api/pkg/events/message_api_sent_event.go +++ b/api/pkg/events/message_api_sent_event.go @@ -22,6 +22,7 @@ type MessageAPISentPayload struct { ScheduledSendTime *time.Time `json:"scheduled_send_time"` RequestReceivedAt time.Time `json:"request_received_at"` Content string `json:"content"` + Attachments []string `json:"attachments"` Encrypted bool `json:"encrypted"` SIM entities.SIM `json:"sim"` } 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/events/user_account_created_event.go b/api/pkg/events/user_account_created_event.go new file mode 100644 index 00000000..2b37ef8a --- /dev/null +++ b/api/pkg/events/user_account_created_event.go @@ -0,0 +1,16 @@ +package events + +import ( + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" +) + +// UserAccountCreated is raised when a user's account is created. +const UserAccountCreated = "user.account.created" + +// UserAccountCreatedPayload stores the data for the UserAccountCreated event +type UserAccountCreatedPayload struct { + UserID entities.UserID `json:"user_id"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/api/pkg/events/user_account_deleted_event.go b/api/pkg/events/user_account_deleted_event.go index 581b44ef..bf8f68db 100644 --- a/api/pkg/events/user_account_deleted_event.go +++ b/api/pkg/events/user_account_deleted_event.go @@ -9,8 +9,9 @@ import ( // UserAccountDeleted is raised when a user's account is deleted. const UserAccountDeleted = "user.account.deleted" -// UserAccountDeletedPayload stores the data for the UserAccountDeletedPayload event +// UserAccountDeletedPayload stores the data for the UserAccountDeleted event type UserAccountDeletedPayload struct { UserID entities.UserID `json:"user_id"` + UserEmail string `json:"user_email"` Timestamp time.Time `json:"timestamp"` } 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/handlers/billing_handler.go b/api/pkg/handlers/billing_handler.go index bcdb5248..3d65ee9a 100644 --- a/api/pkg/handlers/billing_handler.go +++ b/api/pkg/handlers/billing_handler.go @@ -65,7 +65,7 @@ func (h *BillingHandler) UsageHistory(c *fiber.Ctx) error { var request requests.BillingUsageHistory if err := c.QueryParser(&request); err != nil { - msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request) + msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.Body(), request) ctxLogger.Warn(stacktrace.Propagate(err, msg)) return h.responseBadRequest(c, err) } diff --git a/api/pkg/handlers/bulk_message_handler.go b/api/pkg/handlers/bulk_message_handler.go index 5299fb0f..c660eeaa 100644 --- a/api/pkg/handlers/bulk_message_handler.go +++ b/api/pkg/handlers/bulk_message_handler.go @@ -48,13 +48,14 @@ func (h *BulkMessageHandler) RegisterRoutes(router fiber.Router, middlewares ... router.Post("/v1/bulk-messages", h.computeRoute(middlewares, h.Store)...) } -// Store sends bulk SMS messages from a CSV file. +// Store sends bulk SMS messages from a CSV or Excel file. // @Summary Store bulk SMS file -// @Description Sends bulk SMS messages to multiple users from a CSV file. +// @Description Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx). // @Security ApiKeyAuth // @Tags BulkSMS -// @Accept json +// @Accept multipart/form-data // @Produce json +// @Param document formData file true "The Excel or CSV file containing the messages to be sent." // @Success 202 {object} responses.NoContent // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized diff --git a/api/pkg/handlers/lemonsqueezy_handler.go b/api/pkg/handlers/lemonsqueezy_handler.go index a6bfbb85..ca9ea0eb 100644 --- a/api/pkg/handlers/lemonsqueezy_handler.go +++ b/api/pkg/handlers/lemonsqueezy_handler.go @@ -44,18 +44,7 @@ func (h *LemonsqueezyHandler) RegisterRoutes(app *fiber.App, middlewares ...fibe router.Post("/event", h.computeRoute(middlewares, h.Event)...) } -// Event consumes a lemonsqueezy event -// @Summary Consume a lemonsqueezy event -// @Description Publish a lemonsqueezy event to the registered listeners -// @Tags Lemonsqueezy -// @Accept json -// @Produce json -// @Success 204 {object} responses.NoContent -// @Failure 400 {object} responses.BadRequest -// @Failure 401 {object} responses.Unauthorized -// @Failure 422 {object} responses.UnprocessableEntity -// @Failure 500 {object} responses.InternalServerError -// @Router /lemonsqueezy/event [post] +// Event handles lemonsqueezy events func (h *LemonsqueezyHandler) Event(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go index 6f812554..935e9ba4 100644 --- a/api/pkg/handlers/message_handler.go +++ b/api/pkg/handlers/message_handler.go @@ -54,6 +54,7 @@ func (h *MessageHandler) RegisterRoutes(router fiber.Router, middlewares ...fibe router.Post("/v1/messages/bulk-send", h.computeRoute(middlewares, h.BulkSend)...) router.Get("/v1/messages", h.computeRoute(middlewares, h.Index)...) router.Get("/v1/messages/search", h.computeRoute(middlewares, h.Search)...) + router.Get("/v1/messages/:messageID", h.computeRoute(middlewares, h.Get)...) router.Delete("/v1/messages/:messageID", h.computeRoute(middlewares, h.Delete)...) } @@ -66,13 +67,13 @@ func (h *MessageHandler) RegisterPhoneAPIKeyRoutes(router fiber.Router, middlewa } // PostSend a new entities.Message -// @Summary Send a new SMS message -// @Description Add a new SMS message to be sent by the android phone +// @Summary Send an SMS message +// @Description Add a new SMS message to be sent by your Android phone // @Security ApiKeyAuth // @Tags Messages // @Accept json // @Produce json -// @Param payload body requests.MessageSend true "PostSend message request payload" +// @Param payload body requests.MessageSend true "Send message request payload" // @Success 200 {object} responses.MessageResponse // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized @@ -443,6 +444,48 @@ func (h *MessageHandler) Delete(c *fiber.Ctx) error { return h.responseNoContent(c, "message deleted successfully") } +// Get a message +// @Summary Get a message from the database. +// @Description Get a message from the database by the message ID. +// @Security ApiKeyAuth +// @Tags Messages +// @Accept json +// @Produce json +// @Param messageID path string true "ID of the message" default(32343a19-da5e-4b1b-a767-3298a73703ca) +// @Success 204 {object} responses.MessageResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 404 {object} responses.NotFound +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /messages/{messageID} [get] +func (h *MessageHandler) Get(c *fiber.Ctx) error { + ctx, span := h.tracer.StartFromFiberCtx(c) + defer span.End() + + ctxLogger := h.tracer.CtxLogger(h.logger, span) + + messageID := c.Params("messageID") + if errors := h.validator.ValidateUUID(messageID, "messageID"); len(errors) != 0 { + msg := fmt.Sprintf("validation errors [%s], while deleting a message with ID [%s]", spew.Sdump(errors), messageID) + ctxLogger.Warn(stacktrace.NewError(msg)) + return h.responseUnprocessableEntity(c, errors, "validation errors while storing event") + } + + message, err := h.service.GetMessage(ctx, h.userIDFomContext(c), uuid.MustParse(messageID)) + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, fmt.Sprintf("cannot find message with ID [%s]", messageID)) + } + + if err != nil { + msg := fmt.Sprintf("cannot find message with id [%s]", messageID) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) + return h.responseInternalServerError(c) + } + + return h.responseOK(c, "message fetched successfully", message) +} + // PostCallMissed registers a missed phone call // @Summary Register a missed call event on the mobile phone // @Description This endpoint is called by the httpSMS android app to register a missed call event on the mobile phone. diff --git a/api/pkg/handlers/user_handler.go b/api/pkg/handlers/user_handler.go index af77e718..d63046dc 100644 --- a/api/pkg/handlers/user_handler.go +++ b/api/pkg/handlers/user_handler.go @@ -46,6 +46,8 @@ func (h *UserHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.H router.Put("/v1/users/:userID/notifications", h.computeRoute(middlewares, h.UpdateNotifications)...) router.Get("/v1/users/subscription-update-url", h.computeRoute(middlewares, h.subscriptionUpdateURL)...) router.Delete("/v1/users/subscription", h.computeRoute(middlewares, h.cancelSubscription)...) + router.Get("/v1/users/subscription/payments", h.computeRoute(middlewares, h.subscriptionPayments)...) + router.Post("/v1/users/subscription/invoices/:subscriptionInvoiceID", h.computeRoute(middlewares, h.subscriptionInvoice)...) } // Show returns an entities.User @@ -62,14 +64,11 @@ func (h *UserHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.H // @Failure 500 {object} responses.InternalServerError // @Router /users/me [get] func (h *UserHandler) Show(c *fiber.Ctx) error { - ctx, span := h.tracer.StartFromFiberCtx(c) + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() - ctxLogger := h.tracer.CtxLogger(h.logger, span) - authUser := h.userFromContext(c) - - user, err := h.service.Get(ctx, authUser) + user, err := h.service.Get(ctx, c.OriginalURL(), authUser) if err != nil { msg := fmt.Sprintf("cannot get user with ID [%s]", authUser.ID) ctxLogger.Error(stacktrace.Propagate(err, msg)) @@ -112,7 +111,7 @@ func (h *UserHandler) Update(c *fiber.Ctx) error { return h.responseUnprocessableEntity(c, errors, "validation errors while updating user") } - user, err := h.service.Update(ctx, h.userFromContext(c), request.ToUpdateParams()) + user, err := h.service.Update(ctx, c.OriginalURL(), h.userFromContext(c), request.ToUpdateParams()) if err != nil { msg := fmt.Sprintf("cannot update user with params [%+#v]", request) ctxLogger.Error(stacktrace.Propagate(err, msg)) @@ -162,11 +161,9 @@ func (h *UserHandler) Delete(c *fiber.Ctx) error { // @Failure 500 {object} responses.InternalServerError // @Router /users/{userID}/notifications [put] func (h *UserHandler) UpdateNotifications(c *fiber.Ctx) error { - ctx, span := h.tracer.StartFromFiberCtx(c) + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() - ctxLogger := h.tracer.CtxLogger(h.logger, span) - var request requests.UserNotificationUpdate if err := c.BodyParser(&request); err != nil { msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request) @@ -275,3 +272,76 @@ func (h *UserHandler) DeleteAPIKey(c *fiber.Ctx) error { return h.responseOK(c, "API Key rotated successfully", user) } + +// subscriptionPayments returns the last 10 payments of the currently authenticated user +// @Summary Get the last 10 subscription payments. +// @Description Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal. +// @Security ApiKeyAuth +// @Tags Users +// @Accept json +// @Produce json +// @Success 200 {object} responses.UserSubscriptionPaymentsResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /users/subscription/payments [get] +func (h *UserHandler) subscriptionPayments(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + invoices, err := h.service.GetSubscriptionPayments(ctx, h.userIDFomContext(c)) + if err != nil { + msg := fmt.Sprintf("cannot get current subscription invoices for user [%s]", h.userFromContext(c)) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) + } + + return h.responseOK(c, "fetched subscription invoices billing usage", invoices) +} + +// subscriptionInvoice generates an invoice for a given subscription invoice ID +// @Summary Generate a subscription payment invoice +// @Description Generates a new invoice PDF file for the given subscription payment with given parameters. +// @Security ApiKeyAuth +// @Tags Users +// @Accept json +// @Produce application/pdf +// @Param payload body requests.UserPaymentInvoice true "Generate subscription payment invoice parameters" +// @Param subscriptionInvoiceID path string true "ID of the subscription invoice to generate the PDF for" +// @Success 200 {file} file +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /users/subscription/invoices/{subscriptionInvoiceID} [post] +func (h *UserHandler) subscriptionInvoice(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + var request requests.UserPaymentInvoice + if err := c.BodyParser(&request); err != nil { + msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.Body(), request) + ctxLogger.Warn(stacktrace.Propagate(err, msg)) + return h.responseBadRequest(c, err) + } + + request.SubscriptionInvoiceID = c.Params("subscriptionInvoiceID") + if errors := h.validator.ValidatePaymentInvoice(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 { + msg := fmt.Sprintf("validation errors [%s], while validating subscription payment invoice request [%s]", spew.Sdump(errors), c.Body()) + ctxLogger.Warn(stacktrace.NewError(msg)) + return h.responseUnprocessableEntity(c, errors, "validation errors while generating payment invoice") + } + + data, err := h.service.GenerateReceipt(ctx, request.UserInvoiceGenerateParams(h.userIDFomContext(c))) + if err != nil { + msg := fmt.Sprintf("cannot generate receipt for invoice ID [%s] and user [%s]", request.SubscriptionInvoiceID, h.userFromContext(c)) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) + } + + c.Set(fiber.HeaderContentType, "application/pdf") + c.Set(fiber.HeaderContentDisposition, fmt.Sprintf("attachment; filename=\"httpsms.com - %s.pdf\"", request.SubscriptionInvoiceID)) + + return c.SendStream(data) +} diff --git a/api/pkg/listeners/marketing_listener.go b/api/pkg/listeners/marketing_listener.go index fbbb735f..62da4829 100644 --- a/api/pkg/listeners/marketing_listener.go +++ b/api/pkg/listeners/marketing_listener.go @@ -32,9 +32,28 @@ func NewMarketingListener( return l, map[string]events.EventListener{ events.UserAccountDeleted: l.onUserAccountDeleted, + events.UserAccountCreated: l.onUserAccountCreated, } } +func (listener *MarketingListener) onUserAccountCreated(ctx context.Context, event cloudevents.Event) error { + ctx, span := listener.tracer.Start(ctx) + defer span.End() + + var payload events.UserAccountCreatedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if err := listener.service.CreateContact(ctx, payload.UserID); err != nil { + msg := fmt.Sprintf("cannot create [contact] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID()) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} + func (listener *MarketingListener) onUserAccountDeleted(ctx context.Context, event cloudevents.Event) error { ctx, span := listener.tracer.Start(ctx) defer span.End() @@ -45,8 +64,8 @@ func (listener *MarketingListener) onUserAccountDeleted(ctx context.Context, eve return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - if err := listener.service.DeleteUser(ctx, payload.UserID); err != nil { - msg := fmt.Sprintf("cannot delete [sendgrid contact] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID()) + if err := listener.service.DeleteContact(ctx, payload.UserEmail); err != nil { + msg := fmt.Sprintf("cannot delete [contact] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID()) return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } diff --git a/api/pkg/listeners/websocket_listener.go b/api/pkg/listeners/websocket_listener.go index 02e9a36a..2c0e2c17 100644 --- a/api/pkg/listeners/websocket_listener.go +++ b/api/pkg/listeners/websocket_listener.go @@ -32,9 +32,10 @@ func NewWebsocketListener( } return l, map[string]events.EventListener{ - events.EventTypePhoneUpdated: l.onPhoneUpdated, - events.EventTypeMessagePhoneSent: l.onMessagePhoneSent, - events.EventTypeMessageSendFailed: l.onMessagePhoneFailed, + events.EventTypePhoneUpdated: l.onPhoneUpdated, + events.EventTypeMessagePhoneSent: l.onMessagePhoneSent, + events.EventTypeMessageSendFailed: l.onMessagePhoneFailed, + events.EventTypeMessagePhoneReceived: l.onMessagePhoneReceived, } } @@ -57,6 +58,25 @@ func (listener *WebsocketListener) onMessagePhoneSent(ctx context.Context, event return nil } +// onMessagePhoneReceived handles the events.EventTypeMessagePhoneReceived event +func (listener *WebsocketListener) onMessagePhoneReceived(ctx context.Context, event cloudevents.Event) error { + ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) + defer span.End() + + var payload events.MessagePhoneReceivedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event.ID()); err != nil { + msg := fmt.Sprintf("cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} + // onMessagePhoneFailed handles the events.EventTypeMessageSendFailed event func (listener *WebsocketListener) onMessagePhoneFailed(ctx context.Context, event cloudevents.Event) error { ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) diff --git a/api/pkg/middlewares/api_key_auth_middleware.go b/api/pkg/middlewares/api_key_auth_middleware.go index 17ac4335..d971c94a 100644 --- a/api/pkg/middlewares/api_key_auth_middleware.go +++ b/api/pkg/middlewares/api_key_auth_middleware.go @@ -33,7 +33,6 @@ func APIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepository } c.Locals(ContextKeyAuthUserID, authUser) - ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID)) return c.Next() } } diff --git a/api/pkg/middlewares/bearer_api_key_auth_middleware.go b/api/pkg/middlewares/bearer_api_key_auth_middleware.go index 2b1dc1c2..16d9ac5e 100644 --- a/api/pkg/middlewares/bearer_api_key_auth_middleware.go +++ b/api/pkg/middlewares/bearer_api_key_auth_middleware.go @@ -15,11 +15,9 @@ func BearerAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepo logger = logger.WithService("middlewares.APIKeyAuth") return func(c *fiber.Ctx) error { - ctx, span := tracer.StartFromFiberCtx(c, "middlewares.APIKeyAuth") + ctx, span, ctxLogger := tracer.StartFromFiberCtxWithLogger(c, logger, "middlewares.APIKeyAuth") defer span.End() - ctxLogger := tracer.CtxLogger(logger, span) - apiKey := strings.TrimSpace(strings.Replace(c.Get(authHeaderBearer), bearerScheme, "", 1)) if len(apiKey) == 0 { span.AddEvent(fmt.Sprintf("the request header has no [%s] api key", authHeaderAPIKey)) @@ -33,9 +31,6 @@ func BearerAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepo } c.Locals(ContextKeyAuthUserID, authUser) - - ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID)) - return c.Next() } } diff --git a/api/pkg/middlewares/bearer_auth_middleware.go b/api/pkg/middlewares/bearer_auth_middleware.go index 7df1ca3a..ffd29f0d 100644 --- a/api/pkg/middlewares/bearer_auth_middleware.go +++ b/api/pkg/middlewares/bearer_auth_middleware.go @@ -46,8 +46,6 @@ func BearerAuth(logger telemetry.Logger, tracer telemetry.Tracer, authClient *au } c.Locals(ContextKeyAuthUserID, authUser) - - ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID)) return c.Next() } } diff --git a/api/pkg/middlewares/http_request_logger_middleware.go b/api/pkg/middlewares/http_request_logger_middleware.go index bc0146f0..75ddcae2 100644 --- a/api/pkg/middlewares/http_request_logger_middleware.go +++ b/api/pkg/middlewares/http_request_logger_middleware.go @@ -2,6 +2,7 @@ package middlewares import ( "fmt" + "slices" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/gofiber/fiber/v2" @@ -18,17 +19,12 @@ func HTTPRequestLogger(tracer telemetry.Tracer, logger telemetry.Logger) fiber.H _, span, ctxLogger := tracer.StartFromFiberCtxWithLogger(c, logger) defer span.End() - ctxLogger.WithString("http.method", c.Method()). - WithString("http.path", c.Path()). - WithString("client.version", c.Get(clientVersionHeader)). - Trace(fmt.Sprintf("%s %s", c.Method(), c.OriginalURL())) - response := c.Next() statusCode := c.Response().StatusCode() span.AddEvent(fmt.Sprintf("finished handling request with traceID: [%s], statusCode: [%d]", span.SpanContext().TraceID().String(), statusCode)) - if statusCode >= 300 && len(c.Request().Body()) > 0 { - ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("http.status [%d], body [%s]", statusCode, string(c.Request().Body())))) + if statusCode >= 300 && len(c.Request().Body()) > 0 && !slices.Contains([]int{401, 402}, statusCode) { + ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewError(fmt.Sprintf("http.status [%d], body [%s]", statusCode, string(c.Request().Body())))) } return response diff --git a/api/pkg/middlewares/phone_api_key_auth_middleware.go b/api/pkg/middlewares/phone_api_key_auth_middleware.go index dc19c3b8..72bc75ae 100644 --- a/api/pkg/middlewares/phone_api_key_auth_middleware.go +++ b/api/pkg/middlewares/phone_api_key_auth_middleware.go @@ -31,7 +31,6 @@ func PhoneAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, repositor } c.Locals(ContextKeyAuthUserID, authUser) - ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID)) return c.Next() } } 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/gorm_heartbeat_monitor_repository.go b/api/pkg/repositories/gorm_heartbeat_monitor_repository.go index b88c15e2..fb892d87 100644 --- a/api/pkg/repositories/gorm_heartbeat_monitor_repository.go +++ b/api/pkg/repositories/gorm_heartbeat_monitor_repository.go @@ -22,16 +22,30 @@ type gormHeartbeatMonitorRepository struct { db *gorm.DB } +// NewGormHeartbeatMonitorRepository creates the GORM version of the HeartbeatMonitorRepository +func NewGormHeartbeatMonitorRepository( + logger telemetry.Logger, + tracer telemetry.Tracer, + db *gorm.DB, +) HeartbeatMonitorRepository { + return &gormHeartbeatMonitorRepository{ + logger: logger.WithService(fmt.Sprintf("%T", &gormHeartbeatRepository{})), + tracer: tracer, + db: db, + } +} + func (repository *gormHeartbeatMonitorRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { ctx, span := repository.tracer.Start(ctx) defer span.End() - if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.HeartbeatMonitor{}).Error; err != nil { - msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.HeartbeatMonitor{}, userID) - return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) - } - - return nil + return executeWithRetry(func() error { + if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.HeartbeatMonitor{}).Error; err != nil { + msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.HeartbeatMonitor{}, userID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + return nil + }) } // UpdatePhoneOnline updates the online status of a phone @@ -42,14 +56,16 @@ func (repository *gormHeartbeatMonitorRepository) UpdatePhoneOnline(ctx context. ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) defer cancel() - err := repository.db. - Model(&entities.HeartbeatMonitor{}). - Where("id = ?", monitorID). - Where("user_id = ?", userID). - Updates(map[string]any{ - "phone_online": isOnline, - "updated_at": time.Now().UTC(), - }).Error + err := executeWithRetry(func() error { + return repository.db. + Model(&entities.HeartbeatMonitor{}). + Where("id = ?", monitorID). + Where("user_id = ?", userID). + Updates(map[string]any{ + "phone_online": isOnline, + "updated_at": time.Now().UTC(), + }).Error + }) if err != nil { msg := fmt.Sprintf("cannot update heartbeat monitor ID [%s] for user [%s]", monitorID, userID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) @@ -65,13 +81,15 @@ func (repository *gormHeartbeatMonitorRepository) UpdateQueueID(ctx context.Cont ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) defer cancel() - err := repository.db. - Model(&entities.HeartbeatMonitor{}). - Where("id = ?", monitorID). - Updates(map[string]any{ - "queue_id": queueID, - "updated_at": time.Now().UTC(), - }).Error + err := executeWithRetry(func() error { + return repository.db. + Model(&entities.HeartbeatMonitor{}). + Where("id = ?", monitorID). + Updates(map[string]any{ + "queue_id": queueID, + "updated_at": time.Now().UTC(), + }).Error + }) if err != nil { msg := fmt.Sprintf("cannot update heartbeat monitor ID [%s]", monitorID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) @@ -86,10 +104,12 @@ func (repository *gormHeartbeatMonitorRepository) Delete(ctx context.Context, us ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) defer cancel() - err := repository.db.WithContext(ctx). - Where("user_id = ?", userID). - Where("owner = ?", owner). - Delete(&entities.HeartbeatMonitor{}).Error + err := executeWithRetry(func() error { + return repository.db.WithContext(ctx). + Where("user_id = ?", userID). + Where("owner = ?", owner). + Delete(&entities.HeartbeatMonitor{}).Error + }) if err != nil { msg := fmt.Sprintf("cannot delete heartbeat monitor with owner [%s] and userID [%s]", owner, userID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) @@ -98,19 +118,6 @@ func (repository *gormHeartbeatMonitorRepository) Delete(ctx context.Context, us return nil } -// NewGormHeartbeatMonitorRepository creates the GORM version of the HeartbeatMonitorRepository -func NewGormHeartbeatMonitorRepository( - logger telemetry.Logger, - tracer telemetry.Tracer, - db *gorm.DB, -) HeartbeatMonitorRepository { - return &gormHeartbeatMonitorRepository{ - logger: logger.WithService(fmt.Sprintf("%T", &gormHeartbeatRepository{})), - tracer: tracer, - db: db, - } -} - // Index entities.Message between 2 parties func (repository *gormHeartbeatMonitorRepository) Index(ctx context.Context, userID entities.UserID, owner string, params IndexParams) (*[]entities.Heartbeat, error) { ctx, span := repository.tracer.Start(ctx) @@ -121,7 +128,9 @@ func (repository *gormHeartbeatMonitorRepository) Index(ctx context.Context, use query := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("owner = ?", owner) heartbeats := new([]entities.Heartbeat) - if err := query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error; err != nil { + if err := executeWithRetry(func() error { + return query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error + }); err != nil { msg := fmt.Sprintf("cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params) return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } @@ -137,7 +146,7 @@ func (repository *gormHeartbeatMonitorRepository) Store(ctx context.Context, hea ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) defer cancel() - if err := repository.db.WithContext(ctx).Create(heartbeatMonitor).Error; err != nil { + if err := executeWithRetry(func() error { return repository.db.WithContext(ctx).Create(heartbeatMonitor).Error }); err != nil { msg := fmt.Sprintf("cannot save heartbeatMonitor monitor with ID [%s]", heartbeatMonitor.ID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } @@ -154,11 +163,12 @@ func (repository *gormHeartbeatMonitorRepository) Load(ctx context.Context, user defer cancel() phone := new(entities.HeartbeatMonitor) - err := repository.db.WithContext(ctx). - Where("user_id = ?", userID). - Where("owner = ?", owner). - First(&phone).Error - + err := executeWithRetry(func() error { + return repository.db.WithContext(ctx). + Where("user_id = ?", userID). + Where("owner = ?", owner). + First(&phone).Error + }) if errors.Is(err, gorm.ErrRecordNotFound) { msg := fmt.Sprintf("heartbeat monitor with userID [%s] and owner [%s] does not exist", userID, owner) return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) @@ -181,14 +191,16 @@ func (repository *gormHeartbeatMonitorRepository) Exists(ctx context.Context, us defer cancel() var exists bool - err := repository.db.WithContext(ctx). - Model(&entities.HeartbeatMonitor{}). - Select("count(*) > 0"). - Where("user_id = ?", userID). - Where("id = ?", monitorID). - Find(&exists).Error + err := executeWithRetry(func() error { + return repository.db.WithContext(ctx). + Model(&entities.HeartbeatMonitor{}). + Select("count(*) > 0"). + Where("user_id = ?", userID). + Where("id = ?", monitorID). + Find(&exists).Error + }) if err != nil { - msg := fmt.Sprintf("cannot check if heartbeat monitor exists with userID [%s] and montiorID [%s]", userID, monitorID) + msg := fmt.Sprintf("cannot check if heartbeat monitor exists with userID [%s] and montior ID [%s]", userID, monitorID) return exists, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } diff --git a/api/pkg/repositories/gorm_heartbeat_repository.go b/api/pkg/repositories/gorm_heartbeat_repository.go index 8e763f60..5b7794e9 100644 --- a/api/pkg/repositories/gorm_heartbeat_repository.go +++ b/api/pkg/repositories/gorm_heartbeat_repository.go @@ -36,7 +36,10 @@ func (repository *gormHeartbeatRepository) DeleteAllForUser(ctx context.Context, ctx, span := repository.tracer.Start(ctx) defer span.End() - if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Heartbeat{}).Error; err != nil { + err := executeWithRetry(func() error { + return repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Heartbeat{}).Error + }) + if err != nil { msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.Heartbeat{}, userID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } @@ -52,11 +55,13 @@ func (repository *gormHeartbeatRepository) Last(ctx context.Context, userID enti defer cancel() heartbeat := new(entities.Heartbeat) - err := repository.db.WithContext(ctx). - Where("user_id = ?", userID). - Where("owner = ?", owner). - Order("timestamp DESC"). - First(&heartbeat).Error + err := executeWithRetry(func() error { + return repository.db.WithContext(ctx). + Where("user_id = ?", userID). + Where("owner = ?", owner). + Order("timestamp DESC"). + First(&heartbeat).Error + }) if errors.Is(err, gorm.ErrRecordNotFound) { msg := fmt.Sprintf("heartbeat with userID [%s] and owner [%s] does not exist", userID, owner) return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) @@ -85,7 +90,10 @@ func (repository *gormHeartbeatRepository) Index(ctx context.Context, userID ent } heartbeats := new([]entities.Heartbeat) - if err := query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error; err != nil { + err := executeWithRetry(func() error { + return query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error + }) + if err != nil { msg := fmt.Sprintf("cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params) return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } @@ -101,7 +109,7 @@ func (repository *gormHeartbeatRepository) Store(ctx context.Context, heartbeat ctx, cancel := context.WithTimeout(ctx, dbOperationDuration) defer cancel() - if err := repository.db.WithContext(ctx).Create(heartbeat).Error; err != nil { + if err := executeWithRetry(func() error { return repository.db.WithContext(ctx).Create(heartbeat).Error }); err != nil { msg := fmt.Sprintf("cannot save heartbeat with ID [%s]", heartbeat.ID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } diff --git a/api/pkg/repositories/gorm_phone_api_key_repository.go b/api/pkg/repositories/gorm_phone_api_key_repository.go index 692616eb..3c306f5a 100644 --- a/api/pkg/repositories/gorm_phone_api_key_repository.go +++ b/api/pkg/repositories/gorm_phone_api_key_repository.go @@ -10,7 +10,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/dgraph-io/ristretto" + "github.com/dgraph-io/ristretto/v2" "github.com/google/uuid" "github.com/palantir/stacktrace" "gorm.io/gorm" @@ -98,7 +98,6 @@ func (repository *gormPhoneAPIKeyRepository) LoadAuthContext(ctx context.Context defer span.End() if authContext, found := repository.cache.Get(apiKey); found { - ctxLogger.Info(fmt.Sprintf("cache hit for user with ID [%s] and phone API Key ID [%s]", authContext.ID, *authContext.PhoneAPIKeyID)) return authContext, nil } diff --git a/api/pkg/repositories/gorm_phone_repository.go b/api/pkg/repositories/gorm_phone_repository.go index b45f7cff..55b6fb3f 100644 --- a/api/pkg/repositories/gorm_phone_repository.go +++ b/api/pkg/repositories/gorm_phone_repository.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "time" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/dgraph-io/ristretto/v2" "github.com/google/uuid" "github.com/palantir/stacktrace" "gorm.io/gorm" @@ -16,6 +18,7 @@ import ( type gormPhoneRepository struct { logger telemetry.Logger tracer telemetry.Tracer + cache *ristretto.Cache[string, *entities.Phone] db *gorm.DB } @@ -24,11 +27,13 @@ func NewGormPhoneRepository( logger telemetry.Logger, tracer telemetry.Tracer, db *gorm.DB, + cache *ristretto.Cache[string, *entities.Phone], ) PhoneRepository { return &gormPhoneRepository{ logger: logger.WithService(fmt.Sprintf("%T", &gormPhoneRepository{})), tracer: tracer, db: db, + cache: cache, } } @@ -41,6 +46,7 @@ func (repository *gormPhoneRepository) DeleteAllForUser(ctx context.Context, use return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + repository.cache.Clear() return nil } @@ -81,6 +87,7 @@ func (repository *gormPhoneRepository) Delete(ctx context.Context, userID entiti return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + repository.cache.Clear() return nil } @@ -106,14 +113,19 @@ func (repository *gormPhoneRepository) Save(ctx context.Context, phone *entities return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + repository.cache.Del(repository.getCacheKey(phone.UserID, phone.PhoneNumber)) return nil } // Load a phone based on entities.UserID and phoneNumber func (repository *gormPhoneRepository) Load(ctx context.Context, userID entities.UserID, phoneNumber string) (*entities.Phone, error) { - ctx, span := repository.tracer.Start(ctx) + ctx, span, ctxLogger := repository.tracer.StartWithLogger(ctx, repository.logger) defer span.End() + if phone, found := repository.cache.Get(repository.getCacheKey(userID, phoneNumber)); found { + return phone, nil + } + phone := new(entities.Phone) err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("phone_number = ?", phoneNumber).First(phone).Error if errors.Is(err, gorm.ErrRecordNotFound) { @@ -126,6 +138,11 @@ func (repository *gormPhoneRepository) Load(ctx context.Context, userID entities return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + if result := repository.cache.SetWithTTL(repository.getCacheKey(userID, phoneNumber), phone, 1, 30*time.Minute); !result { + msg := fmt.Sprintf("cannot cache [%T] with ID [%s] and result [%t]", phone, phone.ID, result) + ctxLogger.Error(repository.tracer.WrapErrorSpan(span, stacktrace.NewError(msg))) + } + return phone, nil } @@ -147,3 +164,7 @@ func (repository *gormPhoneRepository) Index(ctx context.Context, userID entitie return phones, nil } + +func (repository *gormPhoneRepository) getCacheKey(userID entities.UserID, phoneNumber string) string { + return fmt.Sprintf("user:%s:phone:%s", userID, phoneNumber) +} diff --git a/api/pkg/repositories/gorm_user_repository.go b/api/pkg/repositories/gorm_user_repository.go index c8888399..e31b2848 100644 --- a/api/pkg/repositories/gorm_user_repository.go +++ b/api/pkg/repositories/gorm_user_repository.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm/clause" "github.com/cockroachdb/cockroach-go/v2/crdb/crdbgorm" - "github.com/dgraph-io/ristretto" + "github.com/dgraph-io/ristretto/v2" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -154,13 +154,16 @@ func (repository *gormUserRepository) LoadAuthContext(ctx context.Context, apiKe defer span.End() if authUser, found := repository.cache.Get(apiKey); found { - ctxLogger.Info(fmt.Sprintf("cache hit for user with ID [%s]", authUser.ID)) + if authUser.IsNoop() { + return authUser, repository.tracer.WrapErrorSpan(span, stacktrace.NewError(fmt.Sprintf("user with api key [%s] does not exist", apiKey))) + } return authUser, nil } user := new(entities.User) err := repository.db.WithContext(ctx).Where("api_key = ?", apiKey).First(user).Error if errors.Is(err, gorm.ErrRecordNotFound) { + repository.cache.SetWithTTL(apiKey, entities.AuthContext{}, 1, 2*time.Hour) msg := fmt.Sprintf("user with api key [%s] does not exist", apiKey) return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) } 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/repositories/repository.go b/api/pkg/repositories/repository.go index 32ba4337..4a3e90dc 100644 --- a/api/pkg/repositories/repository.go +++ b/api/pkg/repositories/repository.go @@ -1,8 +1,10 @@ package repositories import ( + "strings" "time" + "github.com/avast/retry-go/v5" "github.com/palantir/stacktrace" ) @@ -21,3 +23,21 @@ const ( dbOperationDuration = 5 * time.Second ) + +// isRetryableError checks if the error is a retryable connection error +func isRetryableError(err error) bool { + msg := err.Error() + return strings.Contains(msg, "bad connection") || + strings.Contains(msg, "stream is closed") || + strings.Contains(msg, "driver: bad connection") +} + +// executeWithRetry executes a GORM query with retry logic for transient connection errors +func executeWithRetry(fn func() error) (err error) { + return retry.New( + retry.LastErrorOnly(true), + retry.Attempts(5), + retry.Delay(100*time.Millisecond), + retry.RetryIf(isRetryableError), + ).Do(fn) +} diff --git a/api/pkg/requests/bulk_message_request.go b/api/pkg/requests/bulk_message_request.go index ffb3f35c..77319997 100644 --- a/api/pkg/requests/bulk_message_request.go +++ b/api/pkg/requests/bulk_message_request.go @@ -18,6 +18,7 @@ type BulkMessage struct { ToPhoneNumber string `csv:"ToPhoneNumber"` Content string `csv:"Content"` SendTime *time.Time `csv:"SendTime(optional)"` + AttachmentURLs string `csv:"AttachmentURLs(optional)" validate:"optional"` // Comma separated list of URLs } // Sanitize sets defaults to BulkMessage @@ -25,12 +26,21 @@ func (input *BulkMessage) Sanitize() *BulkMessage { input.ToPhoneNumber = input.sanitizeAddress(input.ToPhoneNumber) input.Content = strings.TrimSpace(input.Content) input.FromPhoneNumber = input.sanitizeAddress(input.FromPhoneNumber) + + var attachments []string + for _, attachment := range strings.Split(input.AttachmentURLs, ",") { + if strings.TrimSpace(attachment) != "" { + attachments = append(attachments, strings.TrimSpace(attachment)) + } + } + input.AttachmentURLs = strings.Join(attachments, ",") return input } // ToMessageSendParams converts BulkMessage to services.MessageSendParams func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID uuid.UUID, source string) services.MessageSendParams { from, _ := phonenumbers.Parse(input.FromPhoneNumber, phonenumbers.UNKNOWN_REGION) + return services.MessageSendParams{ Source: source, Owner: from, @@ -40,5 +50,6 @@ func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID RequestReceivedAt: time.Now().UTC(), Contact: input.sanitizeAddress(input.ToPhoneNumber), Content: input.Content, + Attachments: input.removeEmptyStrings(strings.Split(input.AttachmentURLs, ",")), } } diff --git a/api/pkg/requests/message_bulk_send_request.go b/api/pkg/requests/message_bulk_send_request.go index a21570bb..461ac2ed 100644 --- a/api/pkg/requests/message_bulk_send_request.go +++ b/api/pkg/requests/message_bulk_send_request.go @@ -1,6 +1,7 @@ package requests import ( + "strings" "time" "github.com/NdoleStudio/httpsms/pkg/entities" @@ -17,8 +18,11 @@ type MessageBulkSend struct { To []string `json:"to" example:"+18005550100,+18005550100"` 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"` + // Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app - Encrypted bool `json:"encrypted" example:"false"` + Encrypted bool `json:"encrypted" example:"false" validate:"optional"` // RequestID is an optional parameter used to track a request from the client's perspective RequestID string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"` @@ -30,6 +34,15 @@ func (input *MessageBulkSend) Sanitize() MessageBulkSend { for _, address := range input.To { to = append(to, input.sanitizeAddress(address)) } + + var attachments []string + for _, attachment := range input.Attachments { + if strings.TrimSpace(attachment) != "" { + attachments = append(attachments, strings.TrimSpace(attachment)) + } + } + + input.Attachments = attachments input.To = to input.From = input.sanitizeAddress(input.From) return *input @@ -52,6 +65,7 @@ func (input *MessageBulkSend) ToMessageSendParams(userID entities.UserID, source Contact: to, SendAt: &sendAt, Content: input.Content, + Attachments: input.Attachments, }) } diff --git a/api/pkg/requests/message_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 0285807d..727cc12e 100644 --- a/api/pkg/requests/message_send_request.go +++ b/api/pkg/requests/message_send_request.go @@ -18,12 +18,15 @@ type MessageSend struct { To string `json:"to" example:"+18005550100"` 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" 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"` // RequestID is an optional parameter used to track a request from the client's perspective RequestID string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"` - // SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone. - SendAt *time.Time `json:"send_at" example:"2022-06-05T14:26:09.527976+03:00" validate:"optional"` + // SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future. + SendAt *time.Time `json:"send_at" example:"2025-12-19T16:39:57-08:00" validate:"optional"` } // Sanitize sets defaults to MessageReceive @@ -31,6 +34,13 @@ func (input *MessageSend) Sanitize() MessageSend { input.To = input.sanitizeAddress(input.To) input.RequestID = strings.TrimSpace(input.RequestID) input.From = input.sanitizeAddress(input.From) + var attachments []string + for _, attachment := range input.Attachments { + if strings.TrimSpace(attachment) != "" { + attachments = append(attachments, strings.TrimSpace(attachment)) + } + } + input.Attachments = attachments return *input } @@ -47,5 +57,6 @@ func (input *MessageSend) ToMessageSendParams(userID entities.UserID, source str RequestReceivedAt: time.Now().UTC(), Contact: input.sanitizeAddress(input.To), Content: input.Content, + Attachments: input.Attachments, } } diff --git a/api/pkg/requests/request.go b/api/pkg/requests/request.go index 851137d1..1db27861 100644 --- a/api/pkg/requests/request.go +++ b/api/pkg/requests/request.go @@ -108,6 +108,18 @@ func (input *request) removeStringDuplicates(values []string) []string { return result } +func (input *request) removeEmptyStrings(values []string) []string { + var result []string + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + result = append(result, value) + } + } + + return result +} + func (input *request) sanitizeMessageID(value string) string { id := strings.Builder{} for _, char := range value { diff --git a/api/pkg/requests/user_payment_invoice_request.go b/api/pkg/requests/user_payment_invoice_request.go new file mode 100644 index 00000000..4d196f4a --- /dev/null +++ b/api/pkg/requests/user_payment_invoice_request.go @@ -0,0 +1,46 @@ +package requests + +import ( + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/services" +) + +// UserPaymentInvoice is the payload for generating a subscription payment invoice +type UserPaymentInvoice struct { + request + Name string `json:"name" example:"Acme Corp"` + Address string `json:"address" example:"221B Baker Street, London"` + City string `json:"city" example:"Los Angeles"` + State string `json:"state" example:"CA"` + Country string `json:"country" example:"US"` + ZipCode string `json:"zip_code" example:"9800"` + Notes string `json:"notes" example:"Thank you for your business!"` + SubscriptionInvoiceID string `json:"subscriptionInvoiceID" swaggerignore:"true"` // used internally for validation +} + +// Sanitize sets defaults to MessageReceive +func (input *UserPaymentInvoice) Sanitize() UserPaymentInvoice { + input.Name = input.sanitizeAddress(input.Name) + input.Address = input.sanitizeAddress(input.Address) + input.City = input.sanitizeAddress(input.City) + input.State = input.sanitizeAddress(input.State) + input.Country = input.sanitizeAddress(input.Country) + input.ZipCode = input.sanitizeAddress(input.ZipCode) + input.Notes = input.sanitizeAddress(input.Notes) + return *input +} + +// UserInvoiceGenerateParams converts UserPaymentInvoice to services.UserInvoiceGenerateParams +func (input *UserPaymentInvoice) UserInvoiceGenerateParams(userID entities.UserID) *services.UserInvoiceGenerateParams { + return &services.UserInvoiceGenerateParams{ + UserID: userID, + SubscriptionInvoiceID: input.SubscriptionInvoiceID, + Name: input.Name, + Address: input.Address, + City: input.City, + State: input.State, + Country: input.Country, + Notes: input.Notes, + ZipCode: input.ZipCode, + } +} diff --git a/api/pkg/responses/billing_responses.go b/api/pkg/responses/billing_responses.go index bb51d6ab..0ce46415 100644 --- a/api/pkg/responses/billing_responses.go +++ b/api/pkg/responses/billing_responses.go @@ -1,6 +1,8 @@ package responses -import "github.com/NdoleStudio/httpsms/pkg/entities" +import ( + "github.com/NdoleStudio/httpsms/pkg/entities" +) // BillingUsagesResponse is the payload containing []entities.BillingUsage type BillingUsagesResponse struct { diff --git a/api/pkg/responses/user_responses.go b/api/pkg/responses/user_responses.go index f2ee6c37..31f95341 100644 --- a/api/pkg/responses/user_responses.go +++ b/api/pkg/responses/user_responses.go @@ -1,9 +1,51 @@ package responses -import "github.com/NdoleStudio/httpsms/pkg/entities" +import ( + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" +) // UserResponse is the payload containing entities.User type UserResponse struct { response Data entities.User `json:"data"` } + +// UserSubscriptionPaymentsResponse is the payload containing lemonsqueezy.SubscriptionInvoicesAPIResponse +type UserSubscriptionPaymentsResponse struct { + response + Data []struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes struct { + BillingReason string `json:"billing_reason"` + CardBrand string `json:"card_brand"` + CardLastFour string `json:"card_last_four"` + Currency string `json:"currency"` + CurrencyRate string `json:"currency_rate"` + Status string `json:"status"` + StatusFormatted string `json:"status_formatted"` + Refunded bool `json:"refunded"` + RefundedAt any `json:"refunded_at"` + Subtotal int `json:"subtotal"` + DiscountTotal int `json:"discount_total"` + Tax int `json:"tax"` + TaxInclusive bool `json:"tax_inclusive"` + Total int `json:"total"` + RefundedAmount int `json:"refunded_amount"` + SubtotalUsd int `json:"subtotal_usd"` + DiscountTotalUsd int `json:"discount_total_usd"` + TaxUsd int `json:"tax_usd"` + TotalUsd int `json:"total_usd"` + RefundedAmountUsd int `json:"refunded_amount_usd"` + SubtotalFormatted string `json:"subtotal_formatted"` + DiscountTotalFormatted string `json:"discount_total_formatted"` + TaxFormatted string `json:"tax_formatted"` + TotalFormatted string `json:"total_formatted"` + RefundedAmountFormatted string `json:"refunded_amount_formatted"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } `json:"attributes"` + } `json:"data"` +} diff --git a/api/pkg/services/discord_service.go b/api/pkg/services/discord_service.go index 059231b9..8c608e9f 100644 --- a/api/pkg/services/discord_service.go +++ b/api/pkg/services/discord_service.go @@ -169,6 +169,12 @@ func (service *DiscordService) createSlashCommand(ctx context.Context, serverID Type: 3, Required: true, }, + { + Name: "attachment_urls", + Description: "Comma-separated list of media URLs to attach", + Type: 3, + Required: false, + }, }, }) if err != nil { diff --git a/api/pkg/services/google_cloud_push_queue_service.go b/api/pkg/services/google_cloud_push_queue_service.go index 194ab296..d22dac83 100644 --- a/api/pkg/services/google_cloud_push_queue_service.go +++ b/api/pkg/services/google_cloud_push_queue_service.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/avast/retry-go" + "github.com/avast/retry-go/v5" cloudtasks "cloud.google.com/go/cloudtasks/apiv2" "cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb" @@ -39,10 +39,10 @@ func NewGooglePushQueue( // Enqueue a task to the queue func (queue *googlePushQueue) Enqueue(ctx context.Context, task *PushQueueTask, timeout time.Duration) (queueID string, err error) { - err = retry.Do(func() error { + err = retry.New(retry.Attempts(3)).Do(func() error { queueID, err = queue.enqueueImpl(ctx, task, timeout) return err - }, retry.Attempts(3)) + }) return queueID, err } diff --git a/api/pkg/services/marketting_service.go b/api/pkg/services/marketting_service.go index 56645dfd..199eb81c 100644 --- a/api/pkg/services/marketting_service.go +++ b/api/pkg/services/marketting_service.go @@ -5,11 +5,12 @@ import ( "fmt" "strings" - "github.com/carlmjohnson/requests" + semconv "go.opentelemetry.io/otel/semconv/v1.10.0" "firebase.google.com/go/auth" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" + plunk "github.com/NdoleStudio/plunk-go" "github.com/gofiber/fiber/v2" "github.com/palantir/stacktrace" ) @@ -19,7 +20,7 @@ type MarketingService struct { logger telemetry.Logger tracer telemetry.Tracer authClient *auth.Client - brevoAPIKey string + plunkClient *plunk.Client } // NewMarketingService creates a new instance of the MarketingService @@ -27,70 +28,71 @@ func NewMarketingService( logger telemetry.Logger, tracer telemetry.Tracer, authClient *auth.Client, - brevoAPIKey string, + plunkClient *plunk.Client, ) *MarketingService { return &MarketingService{ logger: logger.WithService(fmt.Sprintf("%T", &MarketingService{})), tracer: tracer, authClient: authClient, - brevoAPIKey: brevoAPIKey, + plunkClient: plunkClient, } } -// DeleteUser a user if exists in the sendgrid list -func (service *MarketingService) DeleteUser(ctx context.Context, userID entities.UserID) error { +// DeleteContact a user if exists as a contact +func (service *MarketingService) DeleteContact(ctx context.Context, email string) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() - err := requests.URL(fmt.Sprintf("https://api.brevo.com/v3/contacts/%s?identifierType=ext_id", userID)). - Header("api-key", service.brevoAPIKey). - Delete(). - CheckStatus(fiber.StatusNoContent). - Fetch(ctx) + response, _, err := service.plunkClient.Contacts.List(ctx, map[string]string{"search": email}) if err != nil { - return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot delete user with id [%s] from brevo list", userID))) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot search for contact with email [%s]", email))) } - ctxLogger.Info(fmt.Sprintf("deleted user with ID [%s] from brevo list with status [%s]", userID, fiber.StatusNoContent)) + if len(response.Data) == 0 { + ctxLogger.Info(fmt.Sprintf("no contact found with email [%s], skipping deletion", email)) + return nil + } + + contact := response.Data[0] + if _, err = service.plunkClient.Contacts.Delete(ctx, contact.ID); err != nil { + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot delete user with ID [%s] from contacts", contact.Data[string(semconv.EnduserIDKey)]))) + } + + ctxLogger.Info(fmt.Sprintf("deleted user with ID [%s] from as marketting contact with ID [%s]", contact.Data[string(semconv.EnduserIDKey)], contact.ID)) return nil } -// AddToList adds a new user on the onboarding automation. -func (service *MarketingService) AddToList(ctx context.Context, user *entities.User) { +// CreateContact adds a new user on the onboarding automation. +func (service *MarketingService) CreateContact(ctx context.Context, userID entities.UserID) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() - userRecord, err := service.authClient.GetUser(ctx, string(user.ID)) + userRecord, err := service.authClient.GetUser(ctx, userID.String()) if err != nil { - msg := fmt.Sprintf("cannot get auth user with id [%s]", user.ID) - ctxLogger.Error(stacktrace.Propagate(err, msg)) - return + msg := fmt.Sprintf("cannot get auth user with id [%s]", userID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - var response string - err = requests.URL("https://api.brevo.com/v3/contacts"). - Header("api-key", service.brevoAPIKey). - Post(). - BodyJSON(fiber.Map{ - "email": userRecord.Email, - "ext_id": userRecord.UID, - "attributes": service.brevoAttributes(userRecord), - "listIds": []int64{9}, - "updateEnabled": true, - }). - CheckStatus(fiber.StatusCreated, fiber.StatusNoContent). - ToString(&response). - Fetch(ctx) + data := service.attributes(userRecord) + data[string(semconv.ServiceNameKey)] = "httpsms.com" + data[string(semconv.EnduserIDKey)] = userRecord.UID + + event, _, err := service.plunkClient.Tracker.TrackEvent(ctx, &plunk.TrackEventRequest{ + Email: userRecord.Email, + Event: "contact.created", + Subscribed: true, + Data: data, + }) if err != nil { - msg := fmt.Sprintf("cannot add user with id [%s] to brevo list", user.ID) - ctxLogger.Error(stacktrace.Propagate(err, msg)) - return + msg := fmt.Sprintf("cannot create contact for user with id [%s]", userID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - ctxLogger.Info(fmt.Sprintf("user [%s] added to list brevo list with brevo response [%s]", user.ID, response)) + ctxLogger.Info(fmt.Sprintf("user [%s] added to marketting list with contact ID [%s] and event ID [%s]", userID, event.Data.Contact, event.Data.Event)) + return nil } -func (service *MarketingService) brevoAttributes(user *auth.UserRecord) map[string]any { +func (service *MarketingService) attributes(user *auth.UserRecord) map[string]any { name := strings.TrimSpace(user.DisplayName) if name == "" { return fiber.Map{} @@ -98,11 +100,13 @@ func (service *MarketingService) brevoAttributes(user *auth.UserRecord) map[stri parts := strings.Split(name, " ") if len(parts) == 1 { - return fiber.Map{"FIRSTNAME": name} + return fiber.Map{ + "firstName": name, + } } return fiber.Map{ - "FIRSTNAME": strings.Join(parts[0:len(parts)-1], " "), - "LASTNAME": parts[len(parts)-1], + "firstName": strings.Join(parts[0:len(parts)-1], " "), + "lastName": parts[len(parts)-1], } } diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 5a95b265..131c3520 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -2,13 +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" @@ -20,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 @@ -37,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, } } @@ -290,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 @@ -307,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)) @@ -332,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) } @@ -430,6 +455,7 @@ type MessageSendParams struct { Contact string Encrypted bool Content string + Attachments []string Source string SendAt *time.Time RequestID *string @@ -456,6 +482,7 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe Contact: params.Contact, RequestReceivedAt: params.RequestReceivedAt, Content: params.Content, + Attachments: params.Attachments, ScheduledSendTime: params.SendAt, SIM: sim, } @@ -559,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, @@ -579,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 @@ -968,6 +1045,7 @@ func (service *MessageService) storeSentMessage(ctx context.Context, payload eve Contact: payload.Contact, UserID: payload.UserID, Content: payload.Content, + Attachments: payload.Attachments, RequestID: payload.RequestID, SIM: payload.SIM, Encrypted: payload.Encrypted, diff --git a/api/pkg/services/user_service.go b/api/pkg/services/user_service.go index 731f50a9..20c924b4 100644 --- a/api/pkg/services/user_service.go +++ b/api/pkg/services/user_service.go @@ -3,13 +3,13 @@ package services import ( "context" "fmt" + "io" + "net/http" "time" "firebase.google.com/go/auth" - - "github.com/NdoleStudio/httpsms/pkg/events" - "github.com/NdoleStudio/httpsms/pkg/emails" + "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/lemonsqueezy-go" "github.com/NdoleStudio/httpsms/pkg/repositories" @@ -29,9 +29,9 @@ type UserService struct { mailer emails.Mailer repository repositories.UserRepository dispatcher *EventDispatcher - marketingService *MarketingService authClient *auth.Client lemonsqueezyClient *lemonsqueezy.Client + httpClient *http.Client } // NewUserService creates a new UserService @@ -41,26 +41,98 @@ func NewUserService( repository repositories.UserRepository, mailer emails.Mailer, emailFactory emails.UserEmailFactory, - marketingService *MarketingService, lemonsqueezyClient *lemonsqueezy.Client, dispatcher *EventDispatcher, authClient *auth.Client, + httpClient *http.Client, ) (s *UserService) { return &UserService{ logger: logger.WithService(fmt.Sprintf("%T", s)), tracer: tracer, mailer: mailer, - marketingService: marketingService, emailFactory: emailFactory, repository: repository, dispatcher: dispatcher, authClient: authClient, lemonsqueezyClient: lemonsqueezyClient, + httpClient: httpClient, + } +} + +// GetSubscriptionPayments fetches the subscription payments for an entities.User +func (service *UserService) GetSubscriptionPayments(ctx context.Context, userID entities.UserID) (invoices []lemonsqueezy.ApiResponseData[lemonsqueezy.SubscriptionInvoiceAttributes, lemonsqueezy.APIResponseRelationshipsSubscriptionInvoice], err error) { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) + defer span.End() + + user, err := service.repository.Load(ctx, userID) + if err != nil { + msg := fmt.Sprintf("could not get [%T] with with ID [%s]", user, userID) + return invoices, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if user.SubscriptionID == nil { + ctxLogger.Info(fmt.Sprintf("no subscription ID found for [%T] with ID [%s], returning empty invoices", user, user.ID)) + return invoices, nil + } + + ctxLogger.Info(fmt.Sprintf("fetching subscription payments for [%T] with ID [%s] and subscription [%s]", user, user.ID, *user.SubscriptionID)) + invoicesResponse, _, err := service.lemonsqueezyClient.SubscriptionInvoices.List(ctx, map[string]string{"filter[subscription_id]": *user.SubscriptionID}) + if err != nil { + msg := fmt.Sprintf("could not get invoices for subscription [%s] for [%T] with with ID [%s]", *user.SubscriptionID, user, user.ID) + return invoices, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + ctxLogger.Info(fmt.Sprintf("fetched [%d] payments for [%T] with ID [%s] and subscription ID [%s]", len(invoicesResponse.Data), user, user.ID, *user.SubscriptionID)) + return invoicesResponse.Data, nil +} + +// UserInvoiceGenerateParams are parameters for generating a subscription payment invoice +type UserInvoiceGenerateParams struct { + UserID entities.UserID + SubscriptionInvoiceID string + Name string + Address string + City string + State string + Country string + ZipCode string + Notes string +} + +// GenerateReceipt generates a receipt for a subscription payment. +func (service *UserService) GenerateReceipt(ctx context.Context, params *UserInvoiceGenerateParams) (io.Reader, error) { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) + defer span.End() + + payload := map[string]string{ + "name": params.Name, + "address": params.Address, + "city": params.City, + "state": params.State, + "country": params.Country, + "zip_code": params.ZipCode, + "notes": params.Notes, + "locale": "en", } + + invoice, _, err := service.lemonsqueezyClient.SubscriptionInvoices.Generate(ctx, params.SubscriptionInvoiceID, payload) + if err != nil { + msg := fmt.Sprintf("could not generate subscription payment invoice user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + response, err := service.httpClient.Get(invoice.Meta.Urls.DownloadInvoice) + if err != nil { + msg := fmt.Sprintf("could not download subscription payment invoice for user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + ctxLogger.Info(fmt.Sprintf("generated subscription payment invoice for user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID)) + return response.Body, nil } // Get fetches or creates an entities.User -func (service *UserService) Get(ctx context.Context, authUser entities.AuthContext) (*entities.User, error) { +func (service *UserService) Get(ctx context.Context, source string, authUser entities.AuthContext) (*entities.User, error) { ctx, span := service.tracer.Start(ctx) defer span.End() @@ -71,12 +143,33 @@ func (service *UserService) Get(ctx context.Context, authUser entities.AuthConte } if isNew { - service.marketingService.AddToList(ctx, user) + service.dispatchUserCreatedEvent(ctx, source, user) } return user, nil } +func (service *UserService) dispatchUserCreatedEvent(ctx context.Context, source string, user *entities.User) { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) + defer span.End() + + event, err := service.createEvent(events.UserAccountCreated, source, &events.UserAccountCreatedPayload{ + UserID: user.ID, + Timestamp: time.Now().UTC(), + }) + if err != nil { + msg := fmt.Sprintf("cannot create event [%s] for user [%s]", events.UserAccountCreated, user.ID) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return + } + + if err = service.dispatcher.Dispatch(ctx, event); err != nil { + msg := fmt.Sprintf("cannot dispatch [%s] event for user [%s]", event.Type(), user.ID) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return + } +} + // GetByID fetches an entities.User func (service *UserService) GetByID(ctx context.Context, userID entities.UserID) (*entities.User, error) { ctx, span, _ := service.tracer.StartWithLogger(ctx, service.logger) @@ -98,7 +191,7 @@ type UserUpdateParams struct { } // Update an entities.User -func (service *UserService) Update(ctx context.Context, authUser entities.AuthContext, params UserUpdateParams) (*entities.User, error) { +func (service *UserService) Update(ctx context.Context, source string, authUser entities.AuthContext, params UserUpdateParams) (*entities.User, error) { ctx, span := service.tracer.Start(ctx) defer span.End() @@ -111,7 +204,7 @@ func (service *UserService) Update(ctx context.Context, authUser entities.AuthCo } if isNew { - service.marketingService.AddToList(ctx, user) + service.dispatchUserCreatedEvent(ctx, source, user) } user.Timezone = params.Timezone.String() @@ -218,6 +311,7 @@ func (service *UserService) Delete(ctx context.Context, source string, userID en event, err := service.createEvent(events.UserAccountDeleted, source, &events.UserAccountDeletedPayload{ UserID: userID, + UserEmail: user.Email, Timestamp: time.Now().UTC(), }) if err != nil { diff --git a/api/pkg/services/webhook_service.go b/api/pkg/services/webhook_service.go index 0a9a8c75..e4dd0a7b 100644 --- a/api/pkg/services/webhook_service.go +++ b/api/pkg/services/webhook_service.go @@ -11,7 +11,7 @@ import ( "sync" "time" - "github.com/avast/retry-go" + "github.com/avast/retry-go/v5" "github.com/pkg/errors" "github.com/gofiber/fiber/v2" @@ -22,7 +22,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/lib/pq" "github.com/palantir/stacktrace" @@ -212,7 +212,7 @@ func (service *WebhookService) sendNotification(ctx context.Context, event cloud defer span.End() attempts := 0 - err := retry.Do(func() error { + err := retry.New(retry.Attempts(2)).Do(func() error { attempts++ requestCtx, cancel := context.WithTimeout(ctx, 5*time.Second) @@ -230,7 +230,7 @@ func (service *WebhookService) sendNotification(ctx context.Context, event cloud if attempts == 1 { return err } - service.handleWebhookSendFailed(ctx, event, webhook, owner, err, nil) + service.handleWebhookSendFailed(ctx, event, webhook, owner, err, response) return nil } @@ -242,7 +242,7 @@ func (service *WebhookService) sendNotification(ctx context.Context, event cloud }() if response.StatusCode >= 400 { - ctxLogger.Info(fmt.Sprintf("cannot send [%s] event to webhook [%s] for user [%s] with response code [%d]", event.Type(), webhook.URL, webhook.UserID, response.StatusCode)) + ctxLogger.Info(fmt.Sprintf("cannot send [%s] event to webhook [%s] for user [%s] with response code [%d] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, response.StatusCode, attempts)) if attempts == 1 { return stacktrace.NewError(http.StatusText(response.StatusCode)) } @@ -252,7 +252,7 @@ func (service *WebhookService) sendNotification(ctx context.Context, event cloud ctxLogger.Info(fmt.Sprintf("sent webhook to url [%s] for event [%s] with ID [%s] and response code [%d]", webhook.URL, event.Type(), event.ID(), response.StatusCode)) return nil - }, retry.Attempts(2)) + }) if err != nil { msg := fmt.Sprintf("cannot handle [%s] event to webhook [%s] for user [%s] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, attempts) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) @@ -339,12 +339,12 @@ func (service *WebhookService) getPayload(ctxLogger telemetry.Logger, event clou } func (service *WebhookService) getAuthToken(webhook *entities.Webhook) (string, error) { - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{ - Audience: webhook.URL, - ExpiresAt: time.Now().UTC().Add(10 * time.Minute).Unix(), - IssuedAt: time.Now().UTC().Unix(), + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Audience: []string{webhook.URL}, + ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(10 * time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now().UTC()), Issuer: "api.httpsms.com", - NotBefore: time.Now().UTC().Add(-10 * time.Minute).Unix(), + NotBefore: jwt.NewNumericDate(time.Now().UTC().Add(-10 * time.Minute)), Subject: string(webhook.UserID), }) return token.SignedString([]byte(webhook.SigningKey)) diff --git a/api/pkg/validators/bulk_message_handler_validator.go b/api/pkg/validators/bulk_message_handler_validator.go index 4c31ea51..3fa0c35c 100644 --- a/api/pkg/validators/bulk_message_handler_validator.go +++ b/api/pkg/validators/bulk_message_handler_validator.go @@ -12,6 +12,7 @@ import ( "github.com/xuri/excelize/v2" + "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/requests" @@ -30,6 +31,7 @@ type BulkMessageHandlerValidator struct { userService *services.UserService logger telemetry.Logger tracer telemetry.Tracer + cache cache.Cache } // NewBulkMessageHandlerValidator creates a new handlers.BulkMessageHandlerValidator validator @@ -38,12 +40,14 @@ func NewBulkMessageHandlerValidator( tracer telemetry.Tracer, phoneService *services.PhoneService, userService *services.UserService, + appCache cache.Cache, ) (v *BulkMessageHandlerValidator) { return &BulkMessageHandlerValidator{ logger: logger.WithService(fmt.Sprintf("%T", v)), tracer: tracer, userService: userService, phoneService: phoneService, + cache: appCache, } } @@ -79,7 +83,7 @@ func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID messages[index] = message.Sanitize() } - result = v.validateMessages(messages) + result = v.validateMessages(ctx, messages) if len(result) != 0 { return messages, result } @@ -119,6 +123,7 @@ func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, user result.Add("document", fmt.Sprintf("Cannot parse the uploaded excel file with name [%s].", header.Filename)) return nil, result } + defer excel.Close() rows, err := excel.GetRows(excel.GetSheetName(0)) if err != nil { @@ -143,11 +148,17 @@ func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, user } } + var attachmentURLs string + if len(row) > 4 && strings.TrimSpace(row[4]) != "" { + attachmentURLs = strings.TrimSpace(row[4]) + } + messages = append(messages, &requests.BulkMessage{ FromPhoneNumber: strings.TrimSpace(row[0]), ToPhoneNumber: strings.TrimSpace(row[1]), Content: row[2], SendTime: sendAt, + AttachmentURLs: attachmentURLs, }) } @@ -202,16 +213,46 @@ func (v *BulkMessageHandlerValidator) parseCSV(ctxLogger telemetry.Logger, user var messages []*requests.BulkMessage if err := csvutil.Unmarshal(content, &messages); err != nil { ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot unmarshall contents [%s] into type [%T] for file [%s] and user [%s]", content, messages, header.Filename, user.ID))) - result.Add("document", fmt.Sprintf("Cannot read the conents of the uploaded file [%s].", header.Filename)) + result.Add("document", fmt.Sprintf("Cannot read the contents of the uploaded file [%s].", header.Filename)) return nil, result } return messages, url.Values{} } -func (v *BulkMessageHandlerValidator) validateMessages(messages []*requests.BulkMessage) url.Values { +func (v *BulkMessageHandlerValidator) validateMessages(_ context.Context, messages []*requests.BulkMessage) url.Values { result := url.Values{} for index, message := range messages { + + if message.AttachmentURLs != "" { + urls := strings.Split(message.AttachmentURLs, ",") + + validAttachmentCount := 0 + for _, u := range urls { + if strings.TrimSpace(u) != "" { + validAttachmentCount++ + } + } + + if validAttachmentCount > 10 { + result.Add("document", fmt.Sprintf("Row [%d]: You cannot attach more than 10 files per message.", index+2)) + } + + for _, u := range urls { + cleanURL := strings.TrimSpace(u) + if cleanURL == "" { + continue + } + + parsedURL, err := url.ParseRequestURI(cleanURL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + result.Add("document", fmt.Sprintf("Row [%d]: The attachment URL [%s] has an invalid url format.", index+2, cleanURL)) + } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + result.Add("document", fmt.Sprintf("Row [%d]: The attachment URL [%s] must use http or https.", index+2, cleanURL)) + } + } + } + if _, err := phonenumbers.Parse(message.FromPhoneNumber, phonenumbers.UNKNOWN_REGION); err != nil { result.Add("document", fmt.Sprintf("Row [%d]: The FromPhoneNumber [%s] is not a valid E.164 phone number", index+2, message.FromPhoneNumber)) } @@ -224,8 +265,8 @@ func (v *BulkMessageHandlerValidator) validateMessages(messages []*requests.Bulk result.Add("document", fmt.Sprintf("Row [%d]: The message content must be less than 1024 characters.", index+2)) } - if message.SendTime != nil && message.SendTime.After(time.Now().Add(24*time.Hour)) { - result.Add("document", fmt.Sprintf("Row [%d]: The SendTime [%s] cannot be more than 24 hours in the future.", index+2, message.SendTime.Format(time.RFC3339))) + if message.SendTime != nil && message.SendTime.After(time.Now().Add(420*time.Hour)) { + result.Add("document", fmt.Sprintf("Row [%d]: The SendTime [%s] cannot be more than 20 days (420 hours) in the future.", index+2, message.SendTime.Format(time.RFC3339))) } } return result diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index 78cfd024..ee6cf9b2 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -2,10 +2,13 @@ package validators import ( "context" + "encoding/base64" "fmt" "net/url" "strings" + "time" + "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/services" "github.com/palantir/stacktrace" @@ -24,6 +27,7 @@ type MessageHandlerValidator struct { tracer telemetry.Tracer phoneService *services.PhoneService tokenValidator *TurnstileTokenValidator + cache cache.Cache } // NewMessageHandlerValidator creates a new handlers.MessageHandler validator @@ -32,15 +36,23 @@ func NewMessageHandlerValidator( tracer telemetry.Tracer, phoneService *services.PhoneService, tokenValidator *TurnstileTokenValidator, + appCache cache.Cache, ) (v *MessageHandlerValidator) { return &MessageHandlerValidator{ logger: logger.WithService(fmt.Sprintf("%T", v)), tracer: tracer, phoneService: phoneService, tokenValidator: tokenValidator, + cache: appCache, } } +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{ @@ -53,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{ @@ -68,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 @@ -92,6 +152,10 @@ func (validator MessageHandlerValidator) ValidateMessageSend(ctx context.Context "required", phoneNumberRule, }, + "attachments": []string{ + "max:10", + multipleAttachmentURLRule, + }, "content": []string{ "required", "min:1", @@ -105,6 +169,10 @@ func (validator MessageHandlerValidator) ValidateMessageSend(ctx context.Context return result } + if request.SendAt != nil && request.SendAt.After(time.Now().Add(480*time.Hour)) { + result.Add("send_at", "the scheduled time cannot be more than 20 days (480 hours) in the future") + } + _, err := validator.phoneService.Load(ctx, userID, request.From) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { result.Add("from", fmt.Sprintf("no phone found with with 'from' number [%s]. install the android app on your phone to start sending messages", request.From)) @@ -138,6 +206,10 @@ func (validator MessageHandlerValidator) ValidateMessageBulkSend(ctx context.Con "required", phoneNumberRule, }, + "attachments": []string{ + "max:10", + multipleAttachmentURLRule, + }, "content": []string{ "required", "min:1", diff --git a/api/pkg/validators/user_handler_validator.go b/api/pkg/validators/user_handler_validator.go index 553a1cd8..4c05bd1b 100644 --- a/api/pkg/validators/user_handler_validator.go +++ b/api/pkg/validators/user_handler_validator.go @@ -5,26 +5,32 @@ import ( "fmt" "net/url" + "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/palantir/stacktrace" "github.com/thedevsaddam/govalidator" ) // UserHandlerValidator validates models used in handlers.UserHandler type UserHandlerValidator struct { validator - logger telemetry.Logger - tracer telemetry.Tracer + logger telemetry.Logger + tracer telemetry.Tracer + service *services.UserService } // NewUserHandlerValidator creates a new handlers.UserHandler validator func NewUserHandlerValidator( logger telemetry.Logger, tracer telemetry.Tracer, + service *services.UserService, ) (v *UserHandlerValidator) { return &UserHandlerValidator{ - logger: logger.WithService(fmt.Sprintf("%T", v)), - tracer: tracer, + service: service, + logger: logger.WithService(fmt.Sprintf("%T", v)), + tracer: tracer, } } @@ -41,3 +47,83 @@ func (validator *UserHandlerValidator) ValidateUpdate(_ context.Context, request return v.ValidateStruct() } + +// ValidatePaymentInvoice validates the requests.UserPaymentInvoice request +func (validator *UserHandlerValidator) ValidatePaymentInvoice(ctx context.Context, userID entities.UserID, request requests.UserPaymentInvoice) url.Values { + ctx, span, ctxLogger := validator.tracer.StartWithLogger(ctx, validator.logger) + defer span.End() + + rules := govalidator.MapData{ + "name": []string{ + "required", + "min:1", + "max:100", + }, + "address": []string{ + "required", + "min:1", + "max:200", + }, + "city": []string{ + "required", + "min:1", + "max:100", + }, + "state": []string{ + "min:1", + "max:100", + }, + "country": []string{ + "required", + "len:2", + }, + "zip_code": []string{ + "required", + "min:1", + "max:20", + }, + "notes": []string{ + "max:1000", + }, + } + if request.Country == "CA" { + rules["state"] = []string{ + "required", + "in:AB,BC,MB,NB,NL,NS,NT,NU,ON,PE,QC,SK,YT", + } + } + + if request.Country == "US" { + rules["state"] = []string{ + "required", + "in:AL,AK,AZ,AR,CA,CO,CT,DE,FL,GA,HI,ID,IL,IN,IA,KS,KY,LA,ME,MD,MA,MI,MN,MS,MO,MT,NE,NV,NH,NJ,NM,NY,NC,ND,OH,OK,OR,PA,RI,SC,SD,TN,TX,UT,VT,VA,WA,WV,WI,WY", + } + } + + v := govalidator.New(govalidator.Options{ + Data: &request, + Rules: rules, + }) + + validationErrors := v.ValidateStruct() + if len(validationErrors) > 0 { + return validationErrors + } + + payments, err := validator.service.GetSubscriptionPayments(ctx, userID) + if err != nil { + msg := fmt.Sprintf("cannot get subscription payments for user with ID [%s]", userID) + ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) + validationErrors.Add("subscriptionInvoiceID", "failed to validate subscription payment invoice ID") + return validationErrors + } + + for _, payment := range payments { + if payment.ID == request.SubscriptionInvoiceID { + return validationErrors + } + } + + validationErrors.Add("subscriptionInvoiceID", "failed to validate the subscription payment invoice ID") + return validationErrors +} diff --git a/api/pkg/validators/validator.go b/api/pkg/validators/validator.go index bc7111e8..1fcb716a 100644 --- a/api/pkg/validators/validator.go +++ b/api/pkg/validators/validator.go @@ -1,11 +1,15 @@ package validators import ( + "context" "fmt" + "net/http" "net/url" "regexp" "strings" + "time" + "github.com/NdoleStudio/httpsms/pkg/cache" "github.com/NdoleStudio/httpsms/pkg/events" "github.com/nyaruka/phonenumbers" @@ -19,6 +23,7 @@ const ( multiplePhoneNumberRule = "multiplePhoneNumber" contactPhoneNumberRule = "contactPhoneNumber" multipleContactPhoneNumberRule = "multipleContactPhoneNumber" + multipleAttachmentURLRule = "multipleAttachmentURL" multipleInRule = "multipleIn" webhookEventsRule = "webhookEvents" ) @@ -86,6 +91,21 @@ func init() { return nil }) + govalidator.AddCustomRule(multipleAttachmentURLRule, func(field string, rule string, message string, value interface{}) error { + attachments, ok := value.([]string) + if !ok { + return fmt.Errorf("The %s field must be an array of valid attachment URLs", field) + } + + for index, attachment := range attachments { + u, err := url.ParseRequestURI(attachment) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("The attachment %d with URL [%s] must be a valid URL e.g https://placehold.co/600x400", index, attachment) + } + } + return nil + }) + govalidator.AddCustomRule(multipleInRule, func(field string, rule string, message string, value interface{}) error { values, ok := value.([]string) if !ok { @@ -104,7 +124,7 @@ func init() { for index, item := range values { if !contains(item) { - return fmt.Errorf("the %s field in contains an invalid value [%s] at index [%d] ", field, item, index) + return fmt.Errorf("the %s field in contains an invalid value [%s] at index [%d]", field, item, index) } } @@ -160,3 +180,54 @@ func (validator *validator) ValidateUUID(ID string, name string) url.Values { return v.ValidateStruct() } + +func validateAttachmentURL(ctx context.Context, c cache.Cache, attachmentURL string) error { + cacheKey := "mms-url-validation:" + attachmentURL + + if cachedVal, err := c.Get(ctx, cacheKey); err == nil { + if cachedVal == "valid" { + return nil + } + return fmt.Errorf(cachedVal) + } + + client := &http.Client{ + Timeout: 5 * time.Second, + } + + req, err := http.NewRequest(http.MethodHead, attachmentURL, nil) + if err != nil { + errMsg := fmt.Sprintf("invalid url format") + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) + } + + resp, err := client.Do(req) + if err != nil { + errMsg := fmt.Sprintf("could not reach the url") + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + errMsg := fmt.Sprintf("url returned an error status code: %d", resp.StatusCode) + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) + } + + const maxSizeBytes = 1.5 * 1024 * 1024 + + if resp.ContentLength > int64(maxSizeBytes) { + errMsg := fmt.Sprintf("file size (%.2f MB) exceeds the 1.5 MB carrier limit", float64(resp.ContentLength)/(1024*1024)) + saveToCache(ctx, c, cacheKey, errMsg) + return fmt.Errorf(errMsg) + } + + saveToCache(ctx, c, cacheKey, "valid") + return nil +} + +func saveToCache(ctx context.Context, c cache.Cache, key string, value string) { + _ = c.Set(ctx, key, value, 15*time.Minute) +} diff --git a/docker-compose.yml b/docker-compose.yml index 16e885e9..ef63287d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: postgres: image: postgres:alpine @@ -13,7 +11,7 @@ services: - "5435:5432" restart: on-failure healthcheck: - test: ["CMD-SHELL", "pg_isready", "-U", "dbusername", "-d", "httpsms"] + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 30s timeout: 60s retries: 5 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/Dockerfile b/web/Dockerfile index 169f78cc..813399ea 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,5 +1,5 @@ # build stage -FROM node:lts-alpine as build +FROM node:lts-alpine AS build WORKDIR /app diff --git a/web/assets/img/schedule-messages.svg b/web/assets/img/schedule-messages.svg new file mode 100644 index 00000000..5906b27e --- /dev/null +++ b/web/assets/img/schedule-messages.svg @@ -0,0 +1 @@ + diff --git a/web/components/MessageThread.vue b/web/components/MessageThread.vue index 0f0f222b..51676b85 100644 --- a/web/components/MessageThread.vue +++ b/web/components/MessageThread.vue @@ -95,8 +95,10 @@ {{ thread.contact | phoneNumber }} - - {{ thread.last_message_content }} + + + {{ thread.last_message_content }} + @@ -150,6 +152,7 @@ import { mdiCheck, mdiAlert, mdiAccount, + mdiPaperclip, } from '@mdi/js' @Component @@ -160,6 +163,7 @@ export default class MessageThread extends Vue { mdiAlert = mdiAlert mdiCheck = mdiCheck mdiCheckAll = mdiCheckAll + mdiPaperclip = mdiPaperclip get threads(): Array { return this.$store.getters.getThreads diff --git a/web/layouts/default.vue b/web/layouts/default.vue index 1c2049e5..cff2bd34 100644 --- a/web/layouts/default.vue +++ b/web/layouts/default.vue @@ -73,7 +73,7 @@ export default class DefaultLayout extends Vue { if (this.$store.getters.getAuthUser && this.$store.getters.getOwner) { setAuthHeader((await this.$fire.auth.currentUser?.getIdToken()) ?? '') promises.push( - promises.push(this.$store.dispatch('loadPhones', true)), + this.$store.dispatch('loadPhones', true), this.$store.dispatch('loadThreads'), this.$store.dispatch('getHeartbeat'), ) diff --git a/web/models/api.ts b/web/models/api.ts index 7b2d542f..660e7493 100644 --- a/web/models/api.ts +++ b/web/models/api.ts @@ -1,5 +1,3 @@ -/* eslint-disable */ -/* tslint:disable */ // @ts-nocheck /* * --------------------------------------------------------------- @@ -64,8 +62,6 @@ export interface EntitiesHeartbeat { } export interface EntitiesMessage { - /** @example false */ - can_be_polled: boolean /** @example "+18005550100" */ contact: string /** @example "This is a sample text message" */ @@ -73,19 +69,19 @@ export interface EntitiesMessage { /** @example "2022-06-05T14:26:02.302718+03:00" */ created_at: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - delivered_at: string + delivered_at?: string /** @example false */ encrypted: boolean /** @example "2022-06-05T14:26:09.527976+03:00" */ - expired_at: string + expired_at?: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - failed_at: string + failed_at?: string /** @example "UNKNOWN" */ - failure_reason: string + failure_reason?: string /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ id: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - last_attempted_at: string + last_attempted_at?: string /** @example 1 */ max_send_attempts: number /** @example "2022-06-05T14:26:09.527976+03:00" */ @@ -93,24 +89,24 @@ export interface EntitiesMessage { /** @example "+18005550199" */ owner: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - received_at: string + received_at?: string /** @example "153554b5-ae44-44a0-8f4f-7bbac5657ad4" */ - request_id: string + request_id?: string /** @example "2022-06-05T14:26:01.520828+03:00" */ request_received_at: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - scheduled_at: string + scheduled_at?: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - scheduled_send_time: string + scheduled_send_time?: string /** @example 0 */ send_attempt_count: number /** * SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message * @example 133414 */ - send_time: number + send_time?: number /** @example "2022-06-05T14:26:09.527976+03:00" */ - sent_at: string + sent_at?: string /** * SIM is the SIM card to use to send the message * * SMS1: use the SIM card in slot 1 @@ -160,7 +156,7 @@ export interface EntitiesPhone { /** @example "2022-06-05T14:26:02.302718+03:00" */ created_at: string /** @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." */ - fcm_token: string + fcm_token?: string /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ id: string /** @@ -173,7 +169,7 @@ export interface EntitiesPhone { /** @example 1 */ messages_per_minute: number /** @example "This phone cannot receive calls. Please send an SMS instead." */ - missed_call_auto_reply: string + missed_call_auto_reply?: string /** @example "+18005550199" */ phone_number: string /** SIM card that received the message */ @@ -185,7 +181,7 @@ export interface EntitiesPhone { } export interface EntitiesPhoneAPIKey { - /** @example "pk_DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY" */ + /** @example "pk_DGW8NwQp7mxKaSZ72Xq9v6xxxxx" */ api_key: string /** @example "2022-06-05T14:26:02.302718+03:00" */ created_at: string @@ -193,9 +189,9 @@ export interface EntitiesPhoneAPIKey { id: string /** @example "Business Phone Key" */ name: string - /** @example ["[32343a19-da5e-4b1b-a767-3298a73703cb","32343a19-da5e-4b1b-a767-3298a73703cc]"] */ + /** @example ["32343a19-da5e-4b1b-a767-3298a73703cb","32343a19-da5e-4b1b-a767-3298a73703cc"] */ phone_ids: string[] - /** @example ["[+18005550199","+18005550100]"] */ + /** @example ["+18005550199","+18005550100"] */ phone_numbers: string[] /** @example "2022-06-05T14:26:02.302718+03:00" */ updated_at: string @@ -207,7 +203,7 @@ export interface EntitiesPhoneAPIKey { export interface EntitiesUser { /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ - active_phone_id: string + active_phone_id?: string /** @example "x-api-key" */ api_key: string /** @example "2022-06-05T14:26:02.302718+03:00" */ @@ -225,15 +221,15 @@ export interface EntitiesUser { /** @example true */ notification_webhook_enabled: boolean /** @example "2022-06-05T14:26:02.302718+03:00" */ - subscription_ends_at: string + subscription_ends_at?: string /** @example "8f9c71b8-b84e-4417-8408-a62274f65a08" */ subscription_id: string /** @example "free" */ subscription_name: string /** @example "2022-06-05T14:26:02.302718+03:00" */ - subscription_renews_at: string + subscription_renews_at?: string /** @example "on_trial" */ - subscription_status: string + subscription_status?: string /** @example "Europe/Helsinki" */ timezone: string /** @example "2022-06-05T14:26:10.303278+03:00" */ @@ -243,11 +239,11 @@ export interface EntitiesUser { export interface EntitiesWebhook { /** @example "2022-06-05T14:26:02.302718+03:00" */ created_at: string - /** @example ["[message.phone.received]"] */ + /** @example ["message.phone.received"] */ events: string[] /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ id: string - /** @example ["[+18005550199","+18005550100]"] */ + /** @example ["+18005550199","+18005550100"] */ phone_numbers: string[] /** @example "DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY" */ signing_key: string @@ -352,10 +348,10 @@ export interface RequestsMessageSend { /** @example "This is a sample text message" */ content: string /** - * Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app + * Encrypted 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 * @example false */ - encrypted: boolean + encrypted?: boolean /** @example "+18005550199" */ from: string /** @@ -364,8 +360,8 @@ export interface RequestsMessageSend { */ request_id?: string /** - * SendAt is an optional parameter used to schedule a message to be sent at a later time - * @example "2022-06-05T14:26:09.527976+03:00" + * SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future. + * @example "2025-12-19T16:39:57-08:00" */ send_at?: string /** @example "+18005550100" */ @@ -431,6 +427,23 @@ export interface RequestsUserNotificationUpdate { webhook_enabled: boolean } +export interface RequestsUserPaymentInvoice { + /** @example "221B Baker Street, London" */ + address: string + /** @example "Los Angeles" */ + city: string + /** @example "US" */ + country: string + /** @example "Acme Corp" */ + name: string + /** @example "Thank you for your business!" */ + notes: string + /** @example "CA" */ + state: string + /** @example "9800" */ + zip_code: string +} + export interface RequestsUserUpdate { /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ active_phone_id: string @@ -465,7 +478,7 @@ export interface ResponsesBadRequest { export interface ResponsesBillingUsageResponse { data: EntitiesBillingUsage - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -473,7 +486,7 @@ export interface ResponsesBillingUsageResponse { export interface ResponsesBillingUsagesResponse { data: EntitiesBillingUsage[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -481,7 +494,7 @@ export interface ResponsesBillingUsagesResponse { export interface ResponsesDiscordResponse { data: EntitiesDiscord - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -489,7 +502,7 @@ export interface ResponsesDiscordResponse { export interface ResponsesDiscordsResponse { data: EntitiesDiscord[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -497,7 +510,7 @@ export interface ResponsesDiscordsResponse { export interface ResponsesHeartbeatResponse { data: EntitiesHeartbeat - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -505,7 +518,7 @@ export interface ResponsesHeartbeatResponse { export interface ResponsesHeartbeatsResponse { data: EntitiesHeartbeat[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -520,7 +533,7 @@ export interface ResponsesInternalServerError { export interface ResponsesMessageResponse { data: EntitiesMessage - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -528,7 +541,7 @@ export interface ResponsesMessageResponse { export interface ResponsesMessageThreadsResponse { data: EntitiesMessageThread[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -536,7 +549,7 @@ export interface ResponsesMessageThreadsResponse { export interface ResponsesMessagesResponse { data: EntitiesMessage[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -566,7 +579,7 @@ export interface ResponsesOkString { export interface ResponsesPhoneAPIKeyResponse { data: EntitiesPhoneAPIKey - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -574,7 +587,7 @@ export interface ResponsesPhoneAPIKeyResponse { export interface ResponsesPhoneAPIKeysResponse { data: EntitiesPhoneAPIKey[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -582,7 +595,7 @@ export interface ResponsesPhoneAPIKeysResponse { export interface ResponsesPhoneResponse { data: EntitiesPhone - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -590,7 +603,7 @@ export interface ResponsesPhoneResponse { export interface ResponsesPhonesResponse { data: EntitiesPhone[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -607,7 +620,7 @@ export interface ResponsesUnauthorized { export interface ResponsesUnprocessableEntity { data: Record - /** @example "validation errors while sending message" */ + /** @example "validation errors while handling request" */ message: string /** @example "error" */ status: string @@ -615,7 +628,47 @@ export interface ResponsesUnprocessableEntity { export interface ResponsesUserResponse { data: EntitiesUser - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ + message: string + /** @example "success" */ + status: string +} + +export interface ResponsesUserSubscriptionPaymentsResponse { + data: { + attributes: { + billing_reason: string + card_brand: string + card_last_four: string + created_at: string + currency: string + currency_rate: string + discount_total: number + discount_total_formatted: string + discount_total_usd: number + refunded: boolean + refunded_amount: number + refunded_amount_formatted: string + refunded_amount_usd: number + refunded_at: any + status: string + status_formatted: string + subtotal: number + subtotal_formatted: string + subtotal_usd: number + tax: number + tax_formatted: string + tax_inclusive: boolean + tax_usd: number + total: number + total_formatted: string + total_usd: number + updated_at: string + } + id: string + type: string + }[] + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -623,7 +676,7 @@ export interface ResponsesUserResponse { export interface ResponsesWebhookResponse { data: EntitiesWebhook - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -631,7 +684,7 @@ export interface ResponsesWebhookResponse { export interface ResponsesWebhooksResponse { data: EntitiesWebhook[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string diff --git a/web/models/message.ts b/web/models/message.ts index 35306648..b17b53ec 100644 --- a/web/models/message.ts +++ b/web/models/message.ts @@ -1,6 +1,7 @@ export interface Message { contact: string content: string + attachments: Array | null created_at: string failure_reason: string id: string diff --git a/web/package.json b/web/package.json index 56fbec10..9eb11649 100644 --- a/web/package.json +++ b/web/package.json @@ -26,15 +26,15 @@ "@nuxtjs/dotenv": "^1.4.2", "@nuxtjs/firebase": "^8.2.2", "@nuxtjs/sitemap": "^2.4.0", - "chart.js": "^4.5.0", + "chart.js": "^4.5.1", "chartjs-adapter-moment": "^1.0.1", - "core-js": "^3.39.0", + "core-js": "^3.48.0", "date-fns": "^2.30.0", - "dotenv": "^17.2.1", + "dotenv": "^17.2.3", "firebase": "^10.14.1", "firebaseui": "^6.1.0", - "jest-environment-jsdom": "^30.0.5", - "libphonenumber-js": "^1.12.9", + "jest-environment-jsdom": "^30.2.0", + "libphonenumber-js": "^1.12.36", "moment": "^2.30.1", "nuxt": "^2.18.1", "nuxt-highlightjs": "^1.0.3", @@ -42,7 +42,7 @@ "qrcode": "^1.5.0", "ufo": "^1.6.1", "vue": "^2.7.16", - "vue-chartjs": "^5.3.2", + "vue-chartjs": "^5.3.3", "vue-class-component": "^7.2.6", "vue-glow": "^1.4.2", "vue-property-decorator": "^9.1.2", @@ -51,38 +51,38 @@ "vue-template-compiler": "^2.7.16", "vuetify": "^2.7.2", "vuex": "^3.6.2", - "webpack": "^5.101.0" + "webpack": "^5.104.1" }, "devDependencies": { - "@babel/eslint-parser": "^7.27.5", - "@commitlint/cli": "^19.8.1", - "@commitlint/config-conventional": "^19.8.0", + "@babel/eslint-parser": "^7.28.6", + "@commitlint/cli": "^20.4.0", + "@commitlint/config-conventional": "^20.4.0", "@nuxt/types": "^2.18.1", "@nuxt/typescript-build": "^3.0.2", "@nuxtjs/eslint-config-typescript": "^12.1.0", "@nuxtjs/eslint-module": "^4.1.0", "@nuxtjs/stylelint-module": "^5.2.0", "@nuxtjs/vuetify": "^1.12.3", - "@types/qrcode": "^1.5.5", + "@types/qrcode": "^1.5.6", "@vue/test-utils": "^1.3.6", - "axios": "^0.30.0", + "axios": "^0.31.0", "babel-core": "7.0.0-bridge.0", - "babel-jest": "^30.0.5", + "babel-jest": "^30.2.0", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-nuxt": "^4.0.0", "eslint-plugin-vue": "^9.33.0", "highlight.js": "^11.11.1", - "jest": "^30.0.5", + "jest": "^30.2.0", "lint-staged": "^16.1.4", - "node-fetch-native": "^1.6.4", - "postcss-html": "^1.7.0", - "prettier": "3.6.2", + "node-fetch-native": "^1.6.7", + "postcss-html": "^1.8.1", + "prettier": "3.8.1", "stylelint": "^15.11.0", "stylelint-config-prettier": "^9.0.5", "stylelint-config-recommended-vue": "^1.5.0", "stylelint-config-standard": "^34.0.0", - "ts-jest": "^29.4.0", + "ts-jest": "^29.4.6", "vue-client-only": "^2.1.0", "vue-jest": "^3.0.7", "vue-meta": "^2.4.0", diff --git a/web/pages/billing/index.vue b/web/pages/billing/index.vue index d17b7c9a..039f129f 100644 --- a/web/pages/billing/index.vue +++ b/web/pages/billing/index.vue @@ -226,7 +226,7 @@ -
Overview
+
Overview

This is the summary of the sent messages and received messages in -

Usage History
+ +
Usage History

Summary of all the sent and received messages in the past 12 months @@ -337,6 +417,150 @@ + + + Generate Invoice + + Create an invoice for your + {{ selectedPayment?.attributes.total_formatted }} payment on + {{ selectedPayment?.attributes.created_at | timestamp }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ mdiDownloadOutline }} + Download Invoice + + + + Close + + + + @@ -347,8 +571,12 @@ import { mdiAccountCircle, mdiShieldCheck, mdiDelete, + mdiDownloadOutline, mdiCog, mdiContentSave, + mdiCheck, + mdiAlert, + mdiInvoice, mdiEye, mdiEyeOff, mdiCallReceived, @@ -356,6 +584,11 @@ import { mdiCreditCard, mdiSquareEditOutline, } from '@mdi/js' +import { + RequestsUserPaymentInvoice, + ResponsesUserSubscriptionPaymentsResponse, +} from '~/models/api' +import { ErrorMessages } from '~/plugins/errors' type PaymentPlan = { name: string @@ -364,6 +597,14 @@ type PaymentPlan = { messagesPerMonth: number } +type subscriptionPayment = { + attributes: { + created_at: string + total_formatted: string + } + id: string +} + export default Vue.extend({ name: 'BillingIndex', middleware: ['auth'], @@ -372,7 +613,11 @@ export default Vue.extend({ mdiEye, mdiEyeOff, mdiArrowLeft, + mdiDownloadOutline, mdiAccountCircle, + mdiCheck, + mdiAlert, + mdiInvoice, mdiShieldCheck, mdiDelete, mdiCog, @@ -382,7 +627,267 @@ export default Vue.extend({ mdiCreditCard, mdiSquareEditOutline, loading: true, + loadingSubscriptionPayments: false, dialog: false, + payments: null as ResponsesUserSubscriptionPaymentsResponse | null, + selectedPayment: null as subscriptionPayment | null, + errorMessages: new ErrorMessages(), + invoiceFormName: '', + invoiceFormAddress: '', + invoiceFormCity: '', + invoiceFormState: '', + invoiceFormZipCode: '', + invoiceFormCountry: '', + invoiceFormNotes: '', + subscriptionInvoiceDialog: false, + countries: [ + { text: 'Afghanistan', value: 'AF' }, + { text: 'Åland Islands', value: 'AX' }, + { text: 'Albania', value: 'AL' }, + { text: 'Algeria', value: 'DZ' }, + { text: 'American Samoa', value: 'AS' }, + { text: 'Andorra', value: 'AD' }, + { text: 'Angola', value: 'AO' }, + { text: 'Anguilla', value: 'AI' }, + { text: 'Antarctica', value: 'AQ' }, + { text: 'Antigua and Barbuda', value: 'AG' }, + { text: 'Argentina', value: 'AR' }, + { text: 'Armenia', value: 'AM' }, + { text: 'Aruba', value: 'AW' }, + { text: 'Australia', value: 'AU' }, + { text: 'Austria', value: 'AT' }, + { text: 'Azerbaijan', value: 'AZ' }, + { text: 'Bahamas', value: 'BS' }, + { text: 'Bahrain', value: 'BH' }, + { text: 'Bangladesh', value: 'BD' }, + { text: 'Barbados', value: 'BB' }, + { text: 'Belarus', value: 'BY' }, + { text: 'Belgium', value: 'BE' }, + { text: 'Belize', value: 'BZ' }, + { text: 'Benin', value: 'BJ' }, + { text: 'Bermuda', value: 'BM' }, + { text: 'Bhutan', value: 'BT' }, + { text: 'Bolivia', value: 'BO' }, + { text: 'Bonaire', value: 'BQ' }, + { text: 'Bosnia and Herzegovina', value: 'BA' }, + { text: 'Botswana', value: 'BW' }, + { text: 'Bouvet Island', value: 'BV' }, + { text: 'Brazil', value: 'BR' }, + { text: 'British Indian Ocean', value: 'IO' }, + { text: 'Brunei Darussalam', value: 'BN' }, + { text: 'Bulgaria', value: 'BG' }, + { text: 'Burkina Faso', value: 'BF' }, + { text: 'Burundi', value: 'BI' }, + { text: 'Cabo Verde', value: 'CV' }, + { text: 'Cambodia', value: 'KH' }, + { text: 'Cameroon', value: 'CM' }, + { text: 'Canada', value: 'CA' }, + { text: 'Cayman Islands', value: 'KY' }, + { text: 'Central African Republic', value: 'CF' }, + { text: 'Chad', value: 'TD' }, + { text: 'Chile', value: 'CL' }, + { text: 'China', value: 'CN' }, + { text: 'Christmas Island', value: 'CX' }, + { text: 'Cocos (Keeling) Islands', value: 'CC' }, + { text: 'Colombia', value: 'CO' }, + { text: 'Comoros', value: 'KM' }, + { text: 'Congo', value: 'CG' }, + { text: 'Congo', value: 'CD' }, + { text: 'Cook Islands', value: 'CK' }, + { text: 'Costa Rica', value: 'CR' }, + { text: "Côte d'Ivoire", value: 'CI' }, + { text: 'Cuba', value: 'CU' }, + { text: 'Curaçao', value: 'CW' }, + { text: 'Cyprus', value: 'CY' }, + { text: 'Czechia', value: 'CZ' }, + { text: 'Denmark', value: 'DK' }, + { text: 'Djibouti', value: 'DJ' }, + { text: 'Dominica', value: 'DM' }, + { text: 'Dominican Republic', value: 'DO' }, + { text: 'Ecuador', value: 'EC' }, + { text: 'Egypt', value: 'EG' }, + { text: 'El Salvador', value: 'SV' }, + { text: 'Equatorial Guinea', value: 'GQ' }, + { text: 'Eritrea', value: 'ER' }, + { text: 'Estonia', value: 'EE' }, + { text: 'Eswatini', value: 'SZ' }, + { text: 'Ethiopia', value: 'ET' }, + { text: 'Falkland Islands', value: 'FK' }, + { text: 'Faroe Islands', value: 'FO' }, + { text: 'Fiji', value: 'FJ' }, + { text: 'Finland', value: 'FI' }, + { text: 'France', value: 'FR' }, + { text: 'French Guiana', value: 'GF' }, + { text: 'French Polynesia', value: 'PF' }, + { text: 'French Southern Territories', value: 'TF' }, + { text: 'Gabon', value: 'GA' }, + { text: 'Gambia', value: 'GM' }, + { text: 'Georgia', value: 'GE' }, + { text: 'Germany', value: 'DE' }, + { text: 'Ghana', value: 'GH' }, + { text: 'Gibraltar', value: 'GI' }, + { text: 'Greece', value: 'GR' }, + { text: 'Greenland', value: 'GL' }, + { text: 'Grenada', value: 'GD' }, + { text: 'Guadeloupe', value: 'GP' }, + { text: 'Guam', value: 'GU' }, + { text: 'Guatemala', value: 'GT' }, + { text: 'Guernsey', value: 'GG' }, + { text: 'Guinea', value: 'GN' }, + { text: 'Guinea-Bissau', value: 'GW' }, + { text: 'Guyana', value: 'GY' }, + { text: 'Haiti', value: 'HT' }, + { text: 'Heard Island and McDonald Islands', value: 'HM' }, + { text: 'Holy See', value: 'VA' }, + { text: 'Honduras', value: 'HN' }, + { text: 'Hong Kong', value: 'HK' }, + { text: 'Hungary', value: 'HU' }, + { text: 'Iceland', value: 'IS' }, + { text: 'India', value: 'IN' }, + { text: 'Indonesia', value: 'ID' }, + { text: 'Iran', value: 'IR' }, + { text: 'Iraq', value: 'IQ' }, + { text: 'Ireland', value: 'IE' }, + { text: 'Isle of Man', value: 'IM' }, + { text: 'Israel', value: 'IL' }, + { text: 'Italy', value: 'IT' }, + { text: 'Jamaica', value: 'JM' }, + { text: 'Japan', value: 'JP' }, + { text: 'Jersey', value: 'JE' }, + { text: 'Jordan', value: 'JO' }, + { text: 'Kazakhstan', value: 'KZ' }, + { text: 'Kenya', value: 'KE' }, + { text: 'Kiribati', value: 'KI' }, + { text: 'North Korea', value: 'KP' }, + { text: 'South Korea', value: 'KR' }, + { text: 'Kuwait', value: 'KW' }, + { text: 'Kyrgyzstan', value: 'KG' }, + { text: 'Lao People’s Democratic Republic', value: 'LA' }, + { text: 'Latvia', value: 'LV' }, + { text: 'Lebanon', value: 'LB' }, + { text: 'Lesotho', value: 'LS' }, + { text: 'Liberia', value: 'LR' }, + { text: 'Libya', value: 'LY' }, + { text: 'Liechtenstein', value: 'LI' }, + { text: 'Lithuania', value: 'LT' }, + { text: 'Luxembourg', value: 'LU' }, + { text: 'Macao', value: 'MO' }, + { text: 'Madagascar', value: 'MG' }, + { text: 'Malawi', value: 'MW' }, + { text: 'Malaysia', value: 'MY' }, + { text: 'Maldives', value: 'MV' }, + { text: 'Mali', value: 'ML' }, + { text: 'Malta', value: 'MT' }, + { text: 'Marshall Islands', value: 'MH' }, + { text: 'Martinique', value: 'MQ' }, + { text: 'Mauritania', value: 'MR' }, + { text: 'Mauritius', value: 'MU' }, + { text: 'Mayotte', value: 'YT' }, + { text: 'Mexico', value: 'MX' }, + { text: 'Micronesia', value: 'FM' }, + { text: 'Moldova', value: 'MD' }, + { text: 'Monaco', value: 'MC' }, + { text: 'Mongolia', value: 'MN' }, + { text: 'Montenegro', value: 'ME' }, + { text: 'Montserrat', value: 'MS' }, + { text: 'Morocco', value: 'MA' }, + { text: 'Mozambique', value: 'MZ' }, + { text: 'Myanmar', value: 'MM' }, + { text: 'Namibia', value: 'NA' }, + { text: 'Nauru', value: 'NR' }, + { text: 'Nepal', value: 'NP' }, + { text: 'Netherlands', value: 'NL' }, + { text: 'New Caledonia', value: 'NC' }, + { text: 'New Zealand', value: 'NZ' }, + { text: 'Nicaragua', value: 'NI' }, + { text: 'Niger', value: 'NE' }, + { text: 'Nigeria', value: 'NG' }, + { text: 'Niue', value: 'NU' }, + { text: 'Norfolk Island', value: 'NF' }, + { text: 'North Macedonia', value: 'MK' }, + { text: 'Northern Mariana Islands', value: 'MP' }, + { text: 'Norway', value: 'NO' }, + { text: 'Oman', value: 'OM' }, + { text: 'Pakistan', value: 'PK' }, + { text: 'Palau', value: 'PW' }, + { text: 'Panama', value: 'PA' }, + { text: 'Papua New Guinea', value: 'PG' }, + { text: 'Paraguay', value: 'PY' }, + { text: 'Peru', value: 'PE' }, + { text: 'Philippines', value: 'PH' }, + { text: 'Pitcairn', value: 'PN' }, + { text: 'Poland', value: 'PL' }, + { text: 'Portugal', value: 'PT' }, + { text: 'Puerto Rico', value: 'PR' }, + { text: 'Qatar', value: 'QA' }, + { text: 'Réunion', value: 'RE' }, + { text: 'Romania', value: 'RO' }, + { text: 'Russian Federation', value: 'RU' }, + { text: 'Rwanda', value: 'RW' }, + { text: 'Saint Barthélemy', value: 'BL' }, + { text: 'Saint Helena, Ascension and Tristan da Cunha', value: 'SH' }, + { text: 'Saint Kitts and Nevis', value: 'KN' }, + { text: 'Saint Lucia', value: 'LC' }, + { text: 'Saint Martin (French part)', value: 'MF' }, + { text: 'Saint Pierre and Miquelon', value: 'PM' }, + { text: 'Saint Vincent and the Grenadines', value: 'VC' }, + { text: 'Samoa', value: 'WS' }, + { text: 'San Marino', value: 'SM' }, + { text: 'Sao Tome and Principe', value: 'ST' }, + { text: 'Saudi Arabia', value: 'SA' }, + { text: 'Senegal', value: 'SN' }, + { text: 'Serbia', value: 'RS' }, + { text: 'Seychelles', value: 'SC' }, + { text: 'Sierra Leone', value: 'SL' }, + { text: 'Singapore', value: 'SG' }, + { text: 'Slovakia', value: 'SK' }, + { text: 'Slovenia', value: 'SI' }, + { text: 'Solomon Islands', value: 'SB' }, + { text: 'Somalia', value: 'SO' }, + { text: 'South Africa', value: 'ZA' }, + { text: 'South Georgia and the South Sandwich Islands', value: 'GS' }, + { text: 'South Sudan', value: 'SS' }, + { text: 'Spain', value: 'ES' }, + { text: 'Sri Lanka', value: 'LK' }, + { text: 'Sudan', value: 'SD' }, + { text: 'Suriname', value: 'SR' }, + { text: 'Svalbard and Jan Mayen', value: 'SJ' }, + { text: 'Sweden', value: 'SE' }, + { text: 'Switzerland', value: 'CH' }, + { text: 'Syrian Arab Republic', value: 'SY' }, + { text: 'Taiwan, Province of China', value: 'TW' }, + { text: 'Tajikistan', value: 'TJ' }, + { text: 'Tanzania, United Republic of', value: 'TZ' }, + { text: 'Thailand', value: 'TH' }, + { text: 'Timor-Leste', value: 'TL' }, + { text: 'Togo', value: 'TG' }, + { text: 'Tokelau', value: 'TK' }, + { text: 'Tonga', value: 'TO' }, + { text: 'Trinidad and Tobago', value: 'TT' }, + { text: 'Tunisia', value: 'TN' }, + { text: 'Turkey', value: 'TR' }, + { text: 'Turkmenistan', value: 'TM' }, + { text: 'Turks and Caicos Islands', value: 'TC' }, + { text: 'Tuvalu', value: 'TV' }, + { text: 'Uganda', value: 'UG' }, + { text: 'Ukraine', value: 'UA' }, + { text: 'United Arab Emirates', value: 'AE' }, + { text: 'United Kingdom', value: 'GB' }, + { text: 'United States', value: 'US' }, + { text: 'United States Minor Outlying Islands', value: 'UM' }, + { text: 'Uruguay', value: 'UY' }, + { text: 'Uzbekistan', value: 'UZ' }, + { text: 'Vanuatu', value: 'VU' }, + { text: 'Venezuela', value: 'VE' }, + { text: 'Viet Nam', value: 'VN' }, + { text: 'Virgin Islands (British)', value: 'VG' }, + { text: 'Virgin Islands (U.S.)', value: 'VI' }, + { text: 'Wallis and Futuna', value: 'WF' }, + { text: 'Western Sahara', value: 'EH' }, + { text: 'Yemen', value: 'YE' }, + { text: 'Zambia', value: 'ZM' }, + { text: 'Zimbabwe', value: 'ZW' }, + ], plans: [ { name: 'Free', @@ -459,6 +964,81 @@ export default Vue.extend({ } }, computed: { + invoiceStateOptions() { + if (this.invoiceFormCountry === 'US') { + return [ + { text: 'Alabama', value: 'AL' }, + { text: 'Alaska', value: 'AK' }, + { text: 'Arizona', value: 'AZ' }, + { text: 'Arkansas', value: 'AR' }, + { text: 'California', value: 'CA' }, + { text: 'Colorado', value: 'CO' }, + { text: 'Connecticut', value: 'CT' }, + { text: 'Delaware', value: 'DE' }, + { text: 'Florida', value: 'FL' }, + { text: 'Georgia', value: 'GA' }, + { text: 'Hawaii', value: 'HI' }, + { text: 'Idaho', value: 'ID' }, + { text: 'Illinois', value: 'IL' }, + { text: 'Indiana', value: 'IN' }, + { text: 'Iowa', value: 'IA' }, + { text: 'Kansas', value: 'KS' }, + { text: 'Kentucky', value: 'KY' }, + { text: 'Louisiana', value: 'LA' }, + { text: 'Maine', value: 'ME' }, + { text: 'Maryland', value: 'MD' }, + { text: 'Massachusetts', value: 'MA' }, + { text: 'Michigan', value: 'MI' }, + { text: 'Minnesota', value: 'MN' }, + { text: 'Mississippi', value: 'MS' }, + { text: 'Missouri', value: 'MO' }, + { text: 'Montana', value: 'MT' }, + { text: 'Nebraska', value: 'NE' }, + { text: 'Nevada', value: 'NV' }, + { text: 'New Hampshire', value: 'NH' }, + { text: 'New Jersey', value: 'NJ' }, + { text: 'New Mexico', value: 'NM' }, + { text: 'New York', value: 'NY' }, + { text: 'North Carolina', value: 'NC' }, + { text: 'North Dakota', value: 'ND' }, + { text: 'Ohio', value: 'OH' }, + { text: 'Oklahoma', value: 'OK' }, + { text: 'Oregon', value: 'OR' }, + { text: 'Pennsylvania', value: 'PA' }, + { text: 'Rhode Island', value: 'RI' }, + { text: 'South Carolina', value: 'SC' }, + { text: 'South Dakota', value: 'SD' }, + { text: 'Tennessee', value: 'TN' }, + { text: 'Texas', value: 'TX' }, + { text: 'Utah', value: 'UT' }, + { text: 'Vermont', value: 'VT' }, + { text: 'Virginia', value: 'VA' }, + { text: 'Washington', value: 'WA' }, + { text: 'West Virginia', value: 'WV' }, + { text: 'Wisconsin', value: 'WI' }, + { text: 'Wyoming', value: 'WY' }, + { text: 'District of Columbia', value: 'DC' }, + ] + } + if (this.invoiceFormCountry === 'CA') { + return [ + { text: 'Alberta', value: 'AB' }, + { text: 'British Columbia', value: 'BC' }, + { text: 'Manitoba', value: 'MB' }, + { text: 'New Brunswick', value: 'NB' }, + { text: 'Newfoundland and Labrador', value: 'NL' }, + { text: 'Nova Scotia', value: 'NS' }, + { text: 'Ontario', value: 'ON' }, + { text: 'Prince Edward Island', value: 'PE' }, + { text: 'Quebec', value: 'QC' }, + { text: 'Saskatchewan', value: 'SK' }, + { text: 'Northwest Territories', value: 'NT' }, + { text: 'Nunavut', value: 'NU' }, + { text: 'Yukon', value: 'YT' }, + ] + } + return [] + }, checkoutURL() { const url = new URL(this.$config.checkoutURL) const user = this.$store.getters.getAuthUser @@ -513,7 +1093,51 @@ export default Vue.extend({ this.$store.dispatch('loadBillingUsageHistory'), ]) this.loading = false + this.loadSubscriptionInvoices() + }, + + loadSubscriptionInvoices() { + this.loadingSubscriptionPayments = true + this.$store + .dispatch('indexSubscriptionPayments') + .then((response: ResponsesUserSubscriptionPaymentsResponse) => { + this.payments = response + }) + .finally(() => { + this.loadingSubscriptionPayments = false + }) }, + + generateInvoice() { + this.errorMessages = new ErrorMessages() + this.loading = true + this.$store + .dispatch('generateSubscriptionPaymentInvoice', { + subscriptionInvoiceId: this.selectedPayment?.id || '', + request: { + name: this.invoiceFormName, + address: this.invoiceFormAddress, + city: this.invoiceFormCity, + state: this.invoiceFormState, + zip_code: this.invoiceFormZipCode, + country: this.invoiceFormCountry, + notes: this.invoiceFormNotes, + }, + } as { + subscriptionInvoiceId: string + request: RequestsUserPaymentInvoice + }) + .then(() => { + this.subscriptionInvoiceDialog = false + }) + .catch((error: ErrorMessages) => { + this.errorMessages = error + }) + .finally(() => { + this.loading = false + }) + }, + updateDetails() { this.loading = true this.$store @@ -540,6 +1164,11 @@ export default Vue.extend({ this.loading = false }) }, + + showInvoiceDialog(payment: subscriptionPayment) { + this.selectedPayment = payment + this.subscriptionInvoiceDialog = true + }, }, }) diff --git a/web/pages/index.vue b/web/pages/index.vue index dad84598..f40028cb 100644 --- a/web/pages/index.vue +++ b/web/pages/index.vue @@ -51,8 +51,8 @@

- ⚡Trusted by 8,370+ happy users who have sent or received - more than 3,994,092+ messages. + ⚡Trusted by 16,212+ happy users who have sent or received + more than 5,921,545+ messages.

excel template - and upload it on httpSMS to send your SMS messages to multiple + and upload it on httpSMS to send SMS messages to up to 1,000 recipients at once without writing any code. + + +
+

Schedule Text Messages

+
+ Control when your SMS will reach your recipients, allowing you + to perfectly time promotions, critical alerts etc by scheduling + your messages in advance. +
+ {{ mdiClockOutline }}Documentation +
+
+ + + +
@@ -642,6 +668,26 @@ Console.WriteLine(await response.Content.ReadAsStringAsync());
+ + + + + + + @@ -734,23 +780,28 @@ Console.WriteLine(await response.Content.ReadAsStringAsync()); - + -

Ultra

+

+ {{ pricingLabels[pricing] }} Plan +

- Send and receive up to 10,000 SMS messages like a power user. + Send and receive up to {{ planMessages }} SMS messages like a + power user.

- $20/month + ${{ planMonthlyPrice }}/month

- $200/year + ${{ planYearlyPrice }}/year

- or $200 per year + or ${{ planYearlyPrice }} per year

- or $16.66 per month + or ${{ planYearlyMonthlyPrice }} per month

Try For Free{{ mdiCheckCircle }}Send or receive up to 10,000 SMS/month + >Send or receive up to + {{ pricingLabels[pricing] }} SMS/month

@@ -899,7 +951,7 @@ Console.WriteLine(await response.Content.ReadAsStringAsync()); Can I install the app on my Iphone? -