From fdcf11facb199fbed452875d5da9f1ff1207e2f2 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Wed, 8 Apr 2026 11:00:20 +0300 Subject: [PATCH 01/26] Add information about turnstile --- README.md | 37 ++++++++++++++++++++++++++----------- api/.env.docker | 4 ++++ web/.env.docker | 4 ++++ 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ccae7451..84b77a40 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,12 @@ Quick Start Guide 👉 [https://docs.httpsms.com](https://docs.httpsms.com) - [Self Host Setup - Docker](#self-host-setup---docker) - [1. Setup Firebase](#1-setup-firebase) - [2. Setup SMTP Email service](#2-setup-smtp-email-service) - - [3. Download the code](#3-download-the-code) - - [4. Setup the environment variables](#4-setup-the-environment-variables) - - [5. Build and Run](#5-build-and-run) - - [6. Create the System User](#6-create-the-system-user) - - [7. Build the Android App.](#7-build-the-android-app) + - [3. Setup Cloudflare Turnstile](#3-setup-cloudflare-turnstile) + - [4. Download the code](#4-download-the-code) + - [5. Setup the environment variables](#5-setup-the-environment-variables) + - [6. Build and Run](#6-build-and-run) + - [7. Create the System User](#7-create-the-system-user) + - [8. Build the Android App.](#8-build-the-android-app) - [License](#license) @@ -164,7 +165,15 @@ const firebaseConfig = { The httpSMS application uses [SMTP](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol) to send emails to users e.g. when your Android phone has been offline for a long period of time. You can use a service like [mailtrap](https://mailtrap.io/) to create an SMTP server for development purposes. -### 3. Download the code +### 3. Setup Cloudflare Turnstile + +The message search route (`/v1/messages/search`) is protected by a [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/get-started/) captcha to prevent abuse. You need to set up a Turnstile widget for the search messages feature to work. + +1. Go to the [Cloudflare dashboard](https://dash.cloudflare.com/) and navigate to **Turnstile**. +2. Add a new site and configure it for your self-hosted domain (e.g., `localhost` for local development). +3. Note down the **Site Key** and **Secret Key** — you will need them for the frontend and backend environment variables respectively. + +### 4. Download the code Clone the httpSMS GitHub repository @@ -172,7 +181,7 @@ Clone the httpSMS GitHub repository git clone https://github.com/NdoleStudio/httpsms.git ``` -### 4. Setup the environment variables +### 5. Setup the environment variables - Copy the `.env.docker` file in the `web` directory into `.env` @@ -190,6 +199,9 @@ FIREBASE_STORAGE_BUCKET= FIREBASE_MESSAGING_SENDER_ID= FIREBASE_APP_ID= FIREBASE_MEASUREMENT_ID= + +# Cloudflare Turnstile site key from step 3 +CLOUDFLARE_TURNSTILE_SITE_KEY= ``` - Copy the `.env.docker` file in the `api` directory into `.env` @@ -198,7 +210,7 @@ FIREBASE_MEASUREMENT_ID= cp api/.env.docker api/.env ``` -- Update the environment variables in the `.env` file in the `api` directory with your firebase service account credentials and SMTP server details. +- Update the environment variables in the `.env` file in the `api` directory with your firebase service account credentials, SMTP server details, and Cloudflare Turnstile secret key. ```dotenv # SMTP email server settings @@ -212,11 +224,14 @@ FIREBASE_CREDENTIALS= # This is the `projectId` from your firebase web config GCP_PROJECT_ID= + +# Cloudflare Turnstile secret key from step 3 +CLOUDFLARE_TURNSTILE_SECRET_KEY= ``` - Don't bother about the `EVENTS_QUEUE_USER_API_KEY` and `EVENTS_QUEUE_USER_ID` settings. We will set that up later. -### 5. Build and Run +### 6. Build and Run - Build and run the API, the web UI, database and cache using the `docker-compose.yml` file. It takes a while for build and download all the docker images. When it's finished, you'll be able to access the web UI at http://localhost:3000 and the API at http://localhost:8000 @@ -225,7 +240,7 @@ GCP_PROJECT_ID= docker compose up --build ``` -### 6. Create the System User +### 7. Create the System User - The application uses the concept of a system user to process events async. You should manually create this user in `users` table in your database. Make sure you use the same `id` and `api_key` as the `EVENTS_QUEUE_USER_ID`, and `EVENTS_QUEUE_USER_API_KEY` in your `.env` file. @@ -236,7 +251,7 @@ docker compose up --build > [!IMPORTANT] > Restart your API docker container after modifying `EVENTS_QUEUE_USER_ID`, and `EVENTS_QUEUE_USER_API_KEY` in your `.env` file so that the httpSMS API can pick up the changes. -### 7. Build the Android App. +### 8. Build the Android App. - Before building the Android app in [Android Studio](https://developer.android.com/studio), you need to replace the `google-services.json` file in the `android/app` directory with the file which you got from step 1. You need to do this for the firebase FCM messages to work properly. diff --git a/api/.env.docker b/api/.env.docker index 9dc43fdb..2e6be8fb 100644 --- a/api/.env.docker +++ b/api/.env.docker @@ -58,3 +58,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/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= From 727df631079c994b3d199327fcd06b6dc575e6db Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:19:24 +0300 Subject: [PATCH 02/26] docs: add MMS attachment support design spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-04-11-mms-attachments-design.md | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-11-mms-attachments-design.md 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..7b153b60 --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-mms-attachments-design.md @@ -0,0 +1,223 @@ +# 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 with **both** `AuthenticatedMiddleware` (Firebase bearer) **and** `APIKeyMiddleware` — so both the end-user (via Firebase token) and webhook consumers (via API key) can download attachments +- Authenticates that `:userID` matches the authenticated user's ID (from either auth method) +- Returns 401 if mismatch + +**Download flow:** + +1. Parse URL params (userID, messageID, attachmentIndex, filename) +2. Verify authenticated user ID matches `:userID` +3. Construct storage path: `attachments/{userID}/{messageID}/{attachmentIndex}/{filename}` +4. Fetch bytes from `AttachmentStorage.Download(ctx, path)` +5. Derive `Content-Type` from filename extension +6. Set security headers: `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff` +7. Respond with binary data + correct `Content-Type` header +8. 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 with their **API key** (via `x-api-key` header) or **bearer token** to download the attachment. + +### 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 +- UserID mismatch on download → Return 401 Unauthorized +- All errors wrapped with `stacktrace.Propagate()` per project convention From 14af0a1dc065676e0c45d8167af2b1419656226b Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:35:00 +0300 Subject: [PATCH 03/26] docs: add MMS attachments implementation plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plans/2026-04-11-mms-attachments.md | 1139 +++++++++++++++++ .../2026-04-11-mms-attachments-design.md | 21 +- 2 files changed, 1148 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-11-mms-attachments.md 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 index 7b153b60..7beea85f 100644 --- a/docs/superpowers/specs/2026-04-11-mms-attachments-design.md +++ b/docs/superpowers/specs/2026-04-11-mms-attachments-design.md @@ -150,20 +150,18 @@ This covers common MMS content types. New mappings can be added as needed. **Route:** `GET /v1/attachments/:userID/:messageID/:attachmentIndex/:filename` -- Registered with **both** `AuthenticatedMiddleware` (Firebase bearer) **and** `APIKeyMiddleware` — so both the end-user (via Firebase token) and webhook consumers (via API key) can download attachments -- Authenticates that `:userID` matches the authenticated user's ID (from either auth method) -- Returns 401 if mismatch +- 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. Verify authenticated user ID matches `:userID` -3. Construct storage path: `attachments/{userID}/{messageID}/{attachmentIndex}/{filename}` -4. Fetch bytes from `AttachmentStorage.Download(ctx, path)` -5. Derive `Content-Type` from filename extension -6. Set security headers: `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff` -7. Respond with binary data + correct `Content-Type` header -8. Return 404 if attachment not found in storage +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 @@ -183,7 +181,7 @@ type MessagePhoneReceivedPayload struct { } ``` -Webhook subscribers will receive the array of download URLs. They can `GET` each URL with their **API key** (via `x-api-key` header) or **bearer token** to download the attachment. +Webhook subscribers will receive the array of download URLs. They can `GET` each URL directly — no authentication required. ### 8. Files Changed / Created @@ -219,5 +217,4 @@ Webhook subscribers will receive the array of download URLs. They can `GET` each - 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 -- UserID mismatch on download → Return 401 Unauthorized - All errors wrapped with `stacktrace.Propagate()` per project convention From ec48ea0dca95124a709b721b1485fdb74b84baf4 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:38:49 +0300 Subject: [PATCH 04/26] feat: display short attachment names in thread view and fix typo - Show only /{index}/{filename} instead of full URL for received attachments - Fix 'succesfully' typo in message_service.go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/services/message_service.go | 2 +- web/pages/threads/_id/index.vue | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index c7dca231..a667882b 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -331,7 +331,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) } diff --git a/web/pages/threads/_id/index.vue b/web/pages/threads/_id/index.vue index 81931b9a..692efb3a 100644 --- a/web/pages/threads/_id/index.vue +++ b/web/pages/threads/_id/index.vue @@ -186,7 +186,7 @@ {{ mdiPaperclip }} - {{ attachment }} + {{ formatAttachmentName(attachment) }} @@ -460,6 +460,14 @@ export default Vue.extend({ }, methods: { + formatAttachmentName(url: string): string { + const parts = url.split('/') + if (parts.length >= 2) { + return '/' + parts.slice(-2).join('/') + } + return url + }, + isPending(message: Message): boolean { return ['sending', 'pending', 'scheduled'].includes(message.status) }, From 722abc169a6b754862b08ab0423c78f7d879459a Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:40:41 +0300 Subject: [PATCH 05/26] chore: add cloud.google.com/go/storage and golang.org/x/sync deps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/go.mod | 2 +- api/go.sum | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/api/go.mod b/api/go.mod index 94ce0146..cd9e6801 100644 --- a/api/go.mod +++ b/api/go.mod @@ -80,7 +80,7 @@ require ( cloud.google.com/go/iam v1.7.0 // indirect cloud.google.com/go/longrunning v0.9.0 // indirect cloud.google.com/go/monitoring v1.25.0 // indirect - cloud.google.com/go/storage v1.61.3 // indirect + cloud.google.com/go/storage v1.62.0 // indirect cloud.google.com/go/trace v1.12.0 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.2.0 // indirect diff --git a/api/go.sum b/api/go.sum index f55f2c01..3f908ec5 100644 --- a/api/go.sum +++ b/api/go.sum @@ -24,6 +24,8 @@ cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPk cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc= cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= +cloud.google.com/go/storage v1.62.0 h1:w2pQJhpUqVerMON45vatE2FpCYsNTf7OHjkn6ux5mMU= +cloud.google.com/go/storage v1.62.0/go.mod h1:T5hz3qzcpnxZ5LdKc7y8Tw7lh4v9zeeVyrD/cLJAzZU= cloud.google.com/go/trace v1.12.0 h1:XvWHYfr9q88cX4pZyou6qCcSagnuASyUq2ej1dB6NzQ= cloud.google.com/go/trace v1.12.0/go.mod h1:TOYfyeoyCGsSH0ifXD6Aius24uQI9xV3RyvOdljFIyg= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= @@ -377,6 +379,7 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJK go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= go.opentelemetry.io/otel/exporters/stdout/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= From 22fcb133cd7ed530f0e902b798484e6793d1b44a Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:43:30 +0300 Subject: [PATCH 06/26] feat: add AttachmentStorage interface and content-type utilities Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/repositories/attachment_storage.go | 79 +++++++++++++++++++ .../repositories/attachment_storage_test.go | 59 ++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 api/pkg/repositories/attachment_storage.go create mode 100644 api/pkg/repositories/attachment_storage_test.go diff --git a/api/pkg/repositories/attachment_storage.go b/api/pkg/repositories/attachment_storage.go new file mode 100644 index 00000000..843d56a5 --- /dev/null +++ b/api/pkg/repositories/attachment_storage.go @@ -0,0 +1,79 @@ +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 +} diff --git a/api/pkg/repositories/attachment_storage_test.go b/api/pkg/repositories/attachment_storage_test.go new file mode 100644 index 00000000..0512425f --- /dev/null +++ b/api/pkg/repositories/attachment_storage_test.go @@ -0,0 +1,59 @@ +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) + } + }) + } +} From 578c0de747c0f678a512eabf8d41c969e2bbb35e Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:46:38 +0300 Subject: [PATCH 07/26] feat: add attachment fields to request, params, and event structs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../events/message_phone_received_event.go | 17 ++++---- api/pkg/requests/message_receive_request.go | 39 +++++++++++++++---- api/pkg/services/message_service.go | 24 ++++++++---- 3 files changed, 56 insertions(+), 24 deletions(-) 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/requests/message_receive_request.go b/api/pkg/requests/message_receive_request.go index f592761c..be405101 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"` } // Sanitize sets defaults to MessageReceive @@ -38,14 +50,25 @@ func (input *MessageReceive) Sanitize() MessageReceive { // ToMessageReceiveParams converts MessageReceive to services.MessageReceiveParams func (input *MessageReceive) ToMessageReceiveParams(userID entities.UserID, source string) *services.MessageReceiveParams { phone, _ := phonenumbers.Parse(input.To, phonenumbers.UNKNOWN_REGION) + + attachments := make([]services.ServiceAttachment, len(input.Attachments)) + for i, a := range input.Attachments { + attachments[i] = services.ServiceAttachment{ + Name: a.Name, + ContentType: a.ContentType, + Content: a.Content, + } + } + return &services.MessageReceiveParams{ - Source: source, - Contact: input.From, - UserID: userID, - Timestamp: input.Timestamp, - Encrypted: input.Encrypted, - Owner: *phone, - Content: input.Content, - SIM: input.SIM, + Source: source, + Contact: input.From, + UserID: userID, + Timestamp: input.Timestamp, + Encrypted: input.Encrypted, + Owner: *phone, + Content: input.Content, + SIM: input.SIM, + Attachments: attachments, } } diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index a667882b..efddaee8 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -19,6 +19,13 @@ 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 @@ -289,14 +296,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 From ee01c329c6160a9487a49a44c0e7a1cd7a10ed1d Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:47:34 +0300 Subject: [PATCH 08/26] feat: add MemoryAttachmentStorage implementation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../repositories/memory_attachment_storage.go | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 api/pkg/repositories/memory_attachment_storage.go diff --git a/api/pkg/repositories/memory_attachment_storage.go b/api/pkg/repositories/memory_attachment_storage.go new file mode 100644 index 00000000..93047887 --- /dev/null +++ b/api/pkg/repositories/memory_attachment_storage.go @@ -0,0 +1,60 @@ +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 +} From 38a1175592f8ec27397fcef2364dbfb9bb3b9333 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:48:12 +0300 Subject: [PATCH 09/26] feat: add GCSAttachmentStorage implementation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../repositories/gcs_attachment_storage.go | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 api/pkg/repositories/gcs_attachment_storage.go diff --git a/api/pkg/repositories/gcs_attachment_storage.go b/api/pkg/repositories/gcs_attachment_storage.go new file mode 100644 index 00000000..770f7aa9 --- /dev/null +++ b/api/pkg/repositories/gcs_attachment_storage.go @@ -0,0 +1,84 @@ +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 +} From 4cea63ef50901d5250c5bbbcd4681e7542a4b2c8 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:49:38 +0300 Subject: [PATCH 10/26] feat: add attachment count, size, and content-type validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../validators/message_handler_validator.go | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index 9a63886b..7bec234d 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -2,6 +2,7 @@ package validators import ( "context" + "encoding/base64" "fmt" "net/url" "strings" @@ -46,6 +47,11 @@ func NewMessageHandlerValidator( } } +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{ @@ -73,7 +79,47 @@ 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 + } + + 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 } // ValidateMessageSend validates the requests.MessageSend request From c21245b724b618fe441156fe265848b0db125283 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:50:22 +0300 Subject: [PATCH 11/26] feat: add AttachmentHandler for downloading attachments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/handlers/attachment_handler.go | 82 ++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 api/pkg/handlers/attachment_handler.go diff --git a/api/pkg/handlers/attachment_handler.go b/api/pkg/handlers/attachment_handler.go new file mode 100644 index 00000000..ff2950f3 --- /dev/null +++ b/api/pkg/handlers/attachment_handler.go @@ -0,0 +1,82 @@ +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) +} From 0d7f3850559ea793951ca62fa752d4ee4cb0d4f4 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:52:35 +0300 Subject: [PATCH 12/26] feat: add attachment upload logic to MessageService.ReceiveMessage() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/services/message_service.go | 106 +++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 18 deletions(-) diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index efddaee8..38188548 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -2,12 +2,14 @@ package services import ( "context" + "encoding/base64" "fmt" "strings" "time" "github.com/davecgh/go-spew/spew" "github.com/nyaruka/phonenumbers" + "golang.org/x/sync/errgroup" "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/repositories" @@ -29,11 +31,13 @@ type ServiceAttachment struct { // 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 + attachmentStorage repositories.AttachmentStorage + apiBaseURL string } // NewMessageService creates a new MessageService @@ -43,13 +47,17 @@ func NewMessageService( 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, + logger: logger.WithService(fmt.Sprintf("%T", s)), + tracer: tracer, + repository: repository, + phoneService: phoneService, + eventDispatcher: eventDispatcher, + attachmentStorage: attachmentStorage, + apiBaseURL: apiBaseURL, } } @@ -314,15 +322,29 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa 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: 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)) @@ -568,6 +590,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, @@ -588,6 +611,53 @@ 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) { + 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 +} + // HandleMessageParams are parameters for handling a message event type HandleMessageParams struct { ID uuid.UUID From 082eebf91bd6f27ce96561f9724b253915c3f85e Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:56:19 +0300 Subject: [PATCH 13/26] 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 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/.env.docker | 3 ++ api/pkg/di/container.go | 71 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/api/.env.docker b/api/.env.docker index 2e6be8fb..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= diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 14fc9ea4..86fa9347 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -25,6 +25,7 @@ import ( "github.com/NdoleStudio/httpsms/pkg/discord" + "cloud.google.com/go/storage" mexporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric" cloudtrace "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace" "github.com/NdoleStudio/httpsms/pkg/cache" @@ -80,13 +81,14 @@ import ( // Container is used to resolve services at runtime type Container struct { - projectID string - db *gorm.DB - dedicatedDB *gorm.DB - version string - app *fiber.App - eventDispatcher *services.EventDispatcher - logger telemetry.Logger + projectID string + db *gorm.DB + dedicatedDB *gorm.DB + version string + app *fiber.App + eventDispatcher *services.EventDispatcher + logger telemetry.Logger + attachmentStorage repositories.AttachmentStorage } // NewLiteContainer creates a Container without any routes or listeners @@ -118,6 +120,7 @@ func NewContainer(projectID string, version string) (container *Container) { container.RegisterMessageListeners() container.RegisterMessageRoutes() + container.RegisterAttachmentRoutes() container.RegisterBulkMessageRoutes() container.RegisterMessageThreadRoutes() @@ -1430,9 +1433,63 @@ func (container *Container) MessageService() (service *services.MessageService) container.MessageRepository(), container.EventDispatcher(), container.PhoneService(), + container.AttachmentStorage(), + container.APIBaseURL(), ) } +// 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()) +} + // PhoneAPIKeyService creates a new instance of services.PhoneAPIKeyService func (container *Container) PhoneAPIKeyService() (service *services.PhoneAPIKeyService) { container.logger.Debug(fmt.Sprintf("creating %T", service)) From 3201494768fe598338a92457362256a69321ba40 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 12:05:32 +0300 Subject: [PATCH 14/26] fix: address code review findings for MMS attachments - Use deterministic reverse map for ContentTypeFromExtension (I-2) - Initialize attachmentURLs to []string{} to avoid null in JSON (I-3) - Distinguish 404 vs 500 in download handler with ErrAttachmentNotFound (I-4) - Remove unused stacktrace import from memory storage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/handlers/attachment_handler.go | 6 ++++- api/pkg/repositories/attachment_storage.go | 25 ++++++++++++++++--- .../repositories/memory_attachment_storage.go | 3 +-- api/pkg/services/message_service.go | 2 +- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/api/pkg/handlers/attachment_handler.go b/api/pkg/handlers/attachment_handler.go index ff2950f3..75406f34 100644 --- a/api/pkg/handlers/attachment_handler.go +++ b/api/pkg/handlers/attachment_handler.go @@ -1,6 +1,7 @@ package handlers import ( + "errors" "fmt" "path/filepath" @@ -68,7 +69,10 @@ func (h *AttachmentHandler) GetAttachment(c *fiber.Ctx) error { 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") + if errors.Is(err, repositories.ErrAttachmentNotFound) { + return h.responseNotFound(c, "attachment not found") + } + return h.responseInternalServerError(c) } ext := filepath.Ext(filename) diff --git a/api/pkg/repositories/attachment_storage.go b/api/pkg/repositories/attachment_storage.go index 843d56a5..b7b7bf4a 100644 --- a/api/pkg/repositories/attachment_storage.go +++ b/api/pkg/repositories/attachment_storage.go @@ -34,6 +34,22 @@ var contentTypeExtensions = map[string]string{ "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)) @@ -55,14 +71,15 @@ func ExtensionFromContentType(contentType string) string { // 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 - } + if ct, ok := extensionContentTypes[ext]; ok { + return ct } return "application/octet-stream" } +// ErrAttachmentNotFound is returned when an attachment is not found in storage +var ErrAttachmentNotFound = fmt.Errorf("attachment not found") + // 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 { diff --git a/api/pkg/repositories/memory_attachment_storage.go b/api/pkg/repositories/memory_attachment_storage.go index 93047887..602c2509 100644 --- a/api/pkg/repositories/memory_attachment_storage.go +++ b/api/pkg/repositories/memory_attachment_storage.go @@ -6,7 +6,6 @@ import ( "sync" "github.com/NdoleStudio/httpsms/pkg/telemetry" - "github.com/palantir/stacktrace" ) // MemoryAttachmentStorage stores attachments in memory @@ -44,7 +43,7 @@ func (s *MemoryAttachmentStorage) Download(ctx context.Context, path string) ([] value, ok := s.data.Load(path) if !ok { - return nil, stacktrace.NewError(fmt.Sprintf("attachment not found at path [%s]", path)) + return nil, ErrAttachmentNotFound } return value.([]byte), nil } diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 38188548..8ed4a1f3 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -323,7 +323,7 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa ctxLogger := service.tracer.CtxLogger(service.logger, span) messageID := uuid.New() - var attachmentURLs []string + attachmentURLs := []string{} if len(params.Attachments) > 0 { ctxLogger.Info(fmt.Sprintf("uploading [%d] attachments for message [%s]", len(params.Attachments), messageID)) From b79a3e03d23a312f18e01edf251c6b1c9cff6e73 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 12:34:06 +0300 Subject: [PATCH 15/26] refactor: address PR review feedback - rename AttachmentStorage to AttachmentRepository - Rename interface AttachmentStorage -> AttachmentRepository - Rename GCSAttachmentStorage -> GoogleCloudStorageAttachmentRepository - Rename MemoryAttachmentStorage -> MemoryAttachmentRepository - Rename all files: attachment_storage.go -> attachment_repository.go, etc. - Replace ErrAttachmentNotFound with existing ErrCodeNotFound pattern - Map GCS storage.ErrObjectNotExist to ErrCodeNotFound in Download - Use StartWithLogger + ctxLogger in all repository methods - Use StartWithLogger in uploadAttachments service method - Add contentType parameter to Upload interface, set on GCS writer - Mark Attachments field as optional in swagger docs - Fix Swagger @Router to include /v1 prefix - Run go mod tidy to fix indirect marker Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/go.mod | 4 +- api/go.sum | 5 +- api/pkg/di/container.go | 24 ++++---- api/pkg/handlers/attachment_handler.go | 9 ++- ...nt_storage.go => attachment_repository.go} | 11 ++-- ..._test.go => attachment_repository_test.go} | 0 ...torage.go => gcs_attachment_repository.go} | 40 ++++++++----- .../memory_attachment_repository.go | 60 +++++++++++++++++++ .../repositories/memory_attachment_storage.go | 59 ------------------ api/pkg/requests/message_receive_request.go | 2 +- api/pkg/services/message_service.go | 28 ++++----- 11 files changed, 121 insertions(+), 121 deletions(-) rename api/pkg/repositories/{attachment_storage.go => attachment_repository.go} (87%) rename api/pkg/repositories/{attachment_storage_test.go => attachment_repository_test.go} (100%) rename api/pkg/repositories/{gcs_attachment_storage.go => gcs_attachment_repository.go} (51%) create mode 100644 api/pkg/repositories/memory_attachment_repository.go delete mode 100644 api/pkg/repositories/memory_attachment_storage.go diff --git a/api/go.mod b/api/go.mod index cd9e6801..c7073253 100644 --- a/api/go.mod +++ b/api/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( cloud.google.com/go/cloudtasks v1.14.0 + cloud.google.com/go/storage v1.62.0 firebase.google.com/go v3.13.0+incompatible github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.31.0 @@ -50,6 +51,7 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 + golang.org/x/sync v0.20.0 google.golang.org/api v0.274.0 google.golang.org/protobuf v1.36.11 gorm.io/driver/postgres v1.6.0 @@ -80,7 +82,6 @@ require ( cloud.google.com/go/iam v1.7.0 // indirect cloud.google.com/go/longrunning v0.9.0 // indirect cloud.google.com/go/monitoring v1.25.0 // indirect - cloud.google.com/go/storage v1.62.0 // indirect cloud.google.com/go/trace v1.12.0 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.2.0 // indirect @@ -190,7 +191,6 @@ require ( golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/api/go.sum b/api/go.sum index 3f908ec5..9156143b 100644 --- a/api/go.sum +++ b/api/go.sum @@ -22,8 +22,6 @@ cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8 cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPkW7NK4= cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc= -cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= -cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= cloud.google.com/go/storage v1.62.0 h1:w2pQJhpUqVerMON45vatE2FpCYsNTf7OHjkn6ux5mMU= cloud.google.com/go/storage v1.62.0/go.mod h1:T5hz3qzcpnxZ5LdKc7y8Tw7lh4v9zeeVyrD/cLJAzZU= cloud.google.com/go/trace v1.12.0 h1:XvWHYfr9q88cX4pZyou6qCcSagnuASyUq2ej1dB6NzQ= @@ -377,9 +375,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bT go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 86fa9347..de945d6c 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -88,7 +88,7 @@ type Container struct { app *fiber.App eventDispatcher *services.EventDispatcher logger telemetry.Logger - attachmentStorage repositories.AttachmentStorage + attachmentRepository repositories.AttachmentRepository } // NewLiteContainer creates a Container without any routes or listeners @@ -1433,39 +1433,39 @@ func (container *Container) MessageService() (service *services.MessageService) container.MessageRepository(), container.EventDispatcher(), container.PhoneService(), - container.AttachmentStorage(), + container.AttachmentRepository(), container.APIBaseURL(), ) } -// AttachmentStorage creates a cached AttachmentStorage based on configuration -func (container *Container) AttachmentStorage() repositories.AttachmentStorage { - if container.attachmentStorage != nil { - return container.attachmentStorage +// 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 GCSAttachmentStorage") + container.logger.Debug("creating GoogleCloudStorageAttachmentRepository") client, err := storage.NewClient(context.Background()) if err != nil { container.logger.Fatal(stacktrace.Propagate(err, "cannot create GCS client")) } - container.attachmentStorage = repositories.NewGCSAttachmentStorage( + container.attachmentRepository = repositories.NewGoogleCloudStorageAttachmentRepository( container.Logger(), container.Tracer(), client, bucket, ) } else { - container.logger.Debug("creating MemoryAttachmentStorage (GCS_BUCKET_NAME not set)") - container.attachmentStorage = repositories.NewMemoryAttachmentStorage( + container.logger.Debug("creating MemoryAttachmentRepository (GCS_BUCKET_NAME not set)") + container.attachmentRepository = repositories.NewMemoryAttachmentRepository( container.Logger(), container.Tracer(), ) } - return container.attachmentStorage + return container.attachmentRepository } // APIBaseURL returns the API base URL derived from EVENTS_QUEUE_ENDPOINT @@ -1480,7 +1480,7 @@ func (container *Container) AttachmentHandler() (handler *handlers.AttachmentHan return handlers.NewAttachmentHandler( container.Logger(), container.Tracer(), - container.AttachmentStorage(), + container.AttachmentRepository(), ) } diff --git a/api/pkg/handlers/attachment_handler.go b/api/pkg/handlers/attachment_handler.go index 75406f34..e6ea4f85 100644 --- a/api/pkg/handlers/attachment_handler.go +++ b/api/pkg/handlers/attachment_handler.go @@ -1,7 +1,6 @@ package handlers import ( - "errors" "fmt" "path/filepath" @@ -16,14 +15,14 @@ type AttachmentHandler struct { handler logger telemetry.Logger tracer telemetry.Tracer - storage repositories.AttachmentStorage + storage repositories.AttachmentRepository } // NewAttachmentHandler creates a new AttachmentHandler func NewAttachmentHandler( logger telemetry.Logger, tracer telemetry.Tracer, - storage repositories.AttachmentStorage, + storage repositories.AttachmentRepository, ) (h *AttachmentHandler) { return &AttachmentHandler{ logger: logger.WithService(fmt.Sprintf("%T", h)), @@ -49,7 +48,7 @@ func (h *AttachmentHandler) RegisterRoutes(router fiber.Router) { // @Success 200 {file} binary // @Failure 404 {object} responses.NotFoundResponse // @Failure 500 {object} responses.InternalServerError -// @Router /attachments/{userID}/{messageID}/{attachmentIndex}/{filename} [get] +// @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() @@ -69,7 +68,7 @@ func (h *AttachmentHandler) GetAttachment(c *fiber.Ctx) error { if err != nil { msg := fmt.Sprintf("cannot download attachment from path [%s]", path) ctxLogger.Warn(stacktrace.Propagate(err, msg)) - if errors.Is(err, repositories.ErrAttachmentNotFound) { + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { return h.responseNotFound(c, "attachment not found") } return h.responseInternalServerError(c) diff --git a/api/pkg/repositories/attachment_storage.go b/api/pkg/repositories/attachment_repository.go similarity index 87% rename from api/pkg/repositories/attachment_storage.go rename to api/pkg/repositories/attachment_repository.go index b7b7bf4a..cbd77c12 100644 --- a/api/pkg/repositories/attachment_storage.go +++ b/api/pkg/repositories/attachment_repository.go @@ -7,10 +7,10 @@ import ( "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 +// 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 @@ -77,9 +77,6 @@ func ContentTypeFromExtension(ext string) string { return "application/octet-stream" } -// ErrAttachmentNotFound is returned when an attachment is not found in storage -var ErrAttachmentNotFound = fmt.Errorf("attachment not found") - // 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 { diff --git a/api/pkg/repositories/attachment_storage_test.go b/api/pkg/repositories/attachment_repository_test.go similarity index 100% rename from api/pkg/repositories/attachment_storage_test.go rename to api/pkg/repositories/attachment_repository_test.go diff --git a/api/pkg/repositories/gcs_attachment_storage.go b/api/pkg/repositories/gcs_attachment_repository.go similarity index 51% rename from api/pkg/repositories/gcs_attachment_storage.go rename to api/pkg/repositories/gcs_attachment_repository.go index 770f7aa9..d1e0eb92 100644 --- a/api/pkg/repositories/gcs_attachment_storage.go +++ b/api/pkg/repositories/gcs_attachment_repository.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "errors" "fmt" "io" @@ -10,23 +11,23 @@ import ( "github.com/palantir/stacktrace" ) -// GCSAttachmentStorage stores attachments in Google Cloud Storage -type GCSAttachmentStorage struct { +// GoogleCloudStorageAttachmentRepository stores attachments in Google Cloud Storage +type GoogleCloudStorageAttachmentRepository struct { logger telemetry.Logger tracer telemetry.Tracer client *storage.Client bucket string } -// NewGCSAttachmentStorage creates a new GCSAttachmentStorage -func NewGCSAttachmentStorage( +// NewGoogleCloudStorageAttachmentRepository creates a new GoogleCloudStorageAttachmentRepository +func NewGoogleCloudStorageAttachmentRepository( logger telemetry.Logger, tracer telemetry.Tracer, client *storage.Client, bucket string, -) *GCSAttachmentStorage { - return &GCSAttachmentStorage{ - logger: logger.WithService(fmt.Sprintf("%T", &GCSAttachmentStorage{})), +) *GoogleCloudStorageAttachmentRepository { + return &GoogleCloudStorageAttachmentRepository{ + logger: logger.WithService(fmt.Sprintf("%T", &GoogleCloudStorageAttachmentRepository{})), tracer: tracer, client: client, bucket: bucket, @@ -34,11 +35,13 @@ func NewGCSAttachmentStorage( } // 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) +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))) } @@ -47,18 +50,22 @@ func (s *GCSAttachmentStorage) Upload(ctx context.Context, path string, data []b 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))) + 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 *GCSAttachmentStorage) Download(ctx context.Context, path string) ([]byte, error) { - ctx, span := s.tracer.Start(ctx) +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 { - return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot open GCS reader for path [%s]", path))) + 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() @@ -67,18 +74,19 @@ func (s *GCSAttachmentStorage) Download(ctx context.Context, path string) ([]byt 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 *GCSAttachmentStorage) Delete(ctx context.Context, path string) error { - ctx, span := s.tracer.Start(ctx) +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))) } - s.logger.Info(fmt.Sprintf("deleted attachment from GCS path [%s/%s]", s.bucket, path)) + ctxLogger.Info(fmt.Sprintf("deleted attachment from GCS path [%s/%s]", s.bucket, path)) return nil } diff --git a/api/pkg/repositories/memory_attachment_repository.go b/api/pkg/repositories/memory_attachment_repository.go new file mode 100644 index 00000000..65eadf2f --- /dev/null +++ b/api/pkg/repositories/memory_attachment_repository.go @@ -0,0 +1,60 @@ +package repositories + +import ( + "context" + "fmt" + "sync" + + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/palantir/stacktrace" +) + +// MemoryAttachmentRepository stores attachments in memory +type MemoryAttachmentRepository struct { + logger telemetry.Logger + tracer telemetry.Tracer + data sync.Map +} + +// NewMemoryAttachmentRepository creates a new MemoryAttachmentRepository +func NewMemoryAttachmentRepository( + logger telemetry.Logger, + tracer telemetry.Tracer, +) *MemoryAttachmentRepository { + return &MemoryAttachmentRepository{ + logger: logger.WithService(fmt.Sprintf("%T", &MemoryAttachmentRepository{})), + tracer: tracer, + } +} + +// Upload stores attachment data at the given path +func (s *MemoryAttachmentRepository) Upload(ctx context.Context, path string, data []byte, _ string) error { + _, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger) + defer span.End() + + s.data.Store(path, data) + ctxLogger.Info(fmt.Sprintf("stored attachment at path [%s] with size [%d]", path, len(data))) + return nil +} + +// Download retrieves attachment data from the given path +func (s *MemoryAttachmentRepository) Download(ctx context.Context, path string) ([]byte, error) { + _, span, _ := s.tracer.StartWithLogger(ctx, s.logger) + defer span.End() + + value, ok := s.data.Load(path) + if !ok { + return nil, s.tracer.WrapErrorSpan(span, stacktrace.NewErrorWithCode(ErrCodeNotFound, fmt.Sprintf("attachment not found at path [%s]", path))) + } + return value.([]byte), nil +} + +// Delete removes an attachment at the given path +func (s *MemoryAttachmentRepository) Delete(ctx context.Context, path string) error { + _, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger) + defer span.End() + + s.data.Delete(path) + ctxLogger.Info(fmt.Sprintf("deleted attachment at path [%s]", path)) + return nil +} diff --git a/api/pkg/repositories/memory_attachment_storage.go b/api/pkg/repositories/memory_attachment_storage.go deleted file mode 100644 index 602c2509..00000000 --- a/api/pkg/repositories/memory_attachment_storage.go +++ /dev/null @@ -1,59 +0,0 @@ -package repositories - -import ( - "context" - "fmt" - "sync" - - "github.com/NdoleStudio/httpsms/pkg/telemetry" -) - -// 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, ErrAttachmentNotFound - } - 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 -} diff --git a/api/pkg/requests/message_receive_request.go b/api/pkg/requests/message_receive_request.go index be405101..b89cddfa 100644 --- a/api/pkg/requests/message_receive_request.go +++ b/api/pkg/requests/message_receive_request.go @@ -34,7 +34,7 @@ type MessageReceive struct { // 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"` + Attachments []MessageAttachment `json:"attachments" validate:"optional"` } // Sanitize sets defaults to MessageReceive diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 8ed4a1f3..c661da9e 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -36,8 +36,8 @@ type MessageService struct { eventDispatcher *EventDispatcher phoneService *PhoneService repository repositories.MessageRepository - attachmentStorage repositories.AttachmentStorage - apiBaseURL string + attachmentRepository repositories.AttachmentRepository + apiBaseURL string } // NewMessageService creates a new MessageService @@ -47,17 +47,17 @@ func NewMessageService( repository repositories.MessageRepository, eventDispatcher *EventDispatcher, phoneService *PhoneService, - attachmentStorage repositories.AttachmentStorage, + attachmentRepository repositories.AttachmentRepository, 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, + logger: logger.WithService(fmt.Sprintf("%T", s)), + tracer: tracer, + repository: repository, + phoneService: phoneService, + eventDispatcher: eventDispatcher, + attachmentRepository: attachmentRepository, + apiBaseURL: apiBaseURL, } } @@ -612,11 +612,9 @@ func (service *MessageService) storeReceivedMessage(ctx context.Context, params } func (service *MessageService) uploadAttachments(ctx context.Context, userID entities.UserID, messageID uuid.UUID, attachments []ServiceAttachment) ([]string, error) { - ctx, span := service.tracer.Start(ctx) + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) 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)) @@ -636,7 +634,7 @@ func (service *MessageService) uploadAttachments(ctx context.Context, userID ent 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 { + 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)) } @@ -649,7 +647,7 @@ func (service *MessageService) uploadAttachments(ctx context.Context, userID ent if err := g.Wait(); err != nil { for _, path := range paths { if path != "" { - _ = service.attachmentStorage.Delete(ctx, path) + _ = service.attachmentRepository.Delete(ctx, path) } } return nil, stacktrace.Propagate(err, "cannot upload attachments") From a35f8a3695952cbc813590a61aee2b25d3fbac83 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 12:40:05 +0300 Subject: [PATCH 16/26] rename: gcs_attachment_repository.go -> google_cloud_storage_attachment_repository.go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...epository.go => google_cloud_storage_attachment_repository.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename api/pkg/repositories/{gcs_attachment_repository.go => google_cloud_storage_attachment_repository.go} (100%) diff --git a/api/pkg/repositories/gcs_attachment_repository.go b/api/pkg/repositories/google_cloud_storage_attachment_repository.go similarity index 100% rename from api/pkg/repositories/gcs_attachment_repository.go rename to api/pkg/repositories/google_cloud_storage_attachment_repository.go From 556234b3395a43f1a78cd7bdce06ff6b5a54f11d Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 12:47:06 +0300 Subject: [PATCH 17/26] fix: address second round of PR review feedback - Make content field optional when attachments are present (MMS-only messages) - SanitizeFilename: strip all non-alphanumeric chars, replace spaces with dashes - Add 3MB total attachment size limit in validation - Update tests for new SanitizeFilename behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/repositories/attachment_repository.go | 14 +++++++---- .../attachment_repository_test.go | 6 ++++- .../validators/message_handler_validator.go | 23 +++++++++++++------ 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/api/pkg/repositories/attachment_repository.go b/api/pkg/repositories/attachment_repository.go index cbd77c12..f5540cb5 100644 --- a/api/pkg/repositories/attachment_repository.go +++ b/api/pkg/repositories/attachment_repository.go @@ -81,10 +81,16 @@ func ContentTypeFromExtension(ext string) string { // 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) + + var builder strings.Builder + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + builder.WriteRune(r) + } else if r == ' ' { + builder.WriteRune('-') + } + } + name = strings.Trim(builder.String(), "-") if name == "" { return fmt.Sprintf("attachment-%d", index) diff --git a/api/pkg/repositories/attachment_repository_test.go b/api/pkg/repositories/attachment_repository_test.go index 0512425f..76cc5adc 100644 --- a/api/pkg/repositories/attachment_repository_test.go +++ b/api/pkg/repositories/attachment_repository_test.go @@ -43,10 +43,14 @@ func TestSanitizeFilename(t *testing.T) { {"photo.jpg", 0, "photo"}, {"../../etc/passwd", 0, "etcpasswd"}, {"hello/world\\test", 0, "helloworldtest"}, - {"normal_file", 0, "normal_file"}, + {"normal_file", 0, "normalfile"}, {"", 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, "uppercase"}, + {"special!@#chars", 0, "specialchars"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index 7bec234d..126f6a65 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -48,8 +48,9 @@ func NewMessageHandlerValidator( } const ( - maxAttachmentCount = 10 - maxAttachmentSize = (3 * 1024 * 1024) / 2 // 1.5 MB + 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 @@ -64,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{ @@ -102,6 +104,7 @@ func (validator MessageHandlerValidator) validateAttachments(attachments []reque 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)) @@ -117,6 +120,12 @@ func (validator MessageHandlerValidator) validateAttachments(attachments []reque 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 From 4158509abf60dbb6db2f73cdcc617138aa47aef1 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 12:49:52 +0300 Subject: [PATCH 18/26] fix: allow A-Z, underscore, and dash in SanitizeFilename Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/repositories/attachment_repository.go | 4 ++-- api/pkg/repositories/attachment_repository_test.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/pkg/repositories/attachment_repository.go b/api/pkg/repositories/attachment_repository.go index f5540cb5..11d80e20 100644 --- a/api/pkg/repositories/attachment_repository.go +++ b/api/pkg/repositories/attachment_repository.go @@ -83,8 +83,8 @@ func SanitizeFilename(name string, index int) string { name = strings.TrimSuffix(name, filepath.Ext(name)) var builder strings.Builder - for _, r := range strings.ToLower(name) { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + 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('-') diff --git a/api/pkg/repositories/attachment_repository_test.go b/api/pkg/repositories/attachment_repository_test.go index 76cc5adc..1b29fa68 100644 --- a/api/pkg/repositories/attachment_repository_test.go +++ b/api/pkg/repositories/attachment_repository_test.go @@ -43,13 +43,13 @@ func TestSanitizeFilename(t *testing.T) { {"photo.jpg", 0, "photo"}, {"../../etc/passwd", 0, "etcpasswd"}, {"hello/world\\test", 0, "helloworldtest"}, - {"normal_file", 0, "normalfile"}, + {"normal_file", 0, "normal_file"}, {"", 0, "attachment-0"}, {" ", 0, "attachment-0"}, {"...", 1, "attachment-1"}, - {"My Photo", 0, "my-photo"}, + {"My Photo", 0, "My-Photo"}, {"file name with spaces.png", 0, "file-name-with-spaces"}, - {"UPPER_CASE", 0, "uppercase"}, + {"UPPER_CASE", 0, "UPPER_CASE"}, {"special!@#chars", 0, "specialchars"}, } for _, tt := range tests { From de3d99f5ca86a7e7ae5b3ad00d66b2af420c621b Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 12:55:04 +0300 Subject: [PATCH 19/26] fix: simplify uploadAttachments call with := and add user ID to logs - Use attachmentURLs, err := instead of var err error pattern - Add early return in uploadAttachments for empty slices - Include user ID in upload and error log messages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/services/message_service.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index c661da9e..577c7970 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -323,16 +323,12 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa ctxLogger := service.tracer.CtxLogger(service.logger, span) messageID := uuid.New() - 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)) - } + + 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{ @@ -612,6 +608,10 @@ func (service *MessageService) storeReceivedMessage(ctx context.Context, params } 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() From df1083fa06e06020a76063de3f761434de5d33f0 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 13:13:22 +0300 Subject: [PATCH 20/26] Fix auth --- api/pkg/di/container.go | 18 +++++++++--------- web/pages/threads/_id/index.vue | 1 + 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index de945d6c..33d27b01 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -81,13 +81,13 @@ 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 } @@ -398,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)) @@ -1447,7 +1447,7 @@ func (container *Container) AttachmentRepository() repositories.AttachmentReposi bucket := os.Getenv("GCS_BUCKET_NAME") if bucket != "" { container.logger.Debug("creating GoogleCloudStorageAttachmentRepository") - client, err := storage.NewClient(context.Background()) + 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")) } diff --git a/web/pages/threads/_id/index.vue b/web/pages/threads/_id/index.vue index 692efb3a..fc0e27f3 100644 --- a/web/pages/threads/_id/index.vue +++ b/web/pages/threads/_id/index.vue @@ -162,6 +162,7 @@ :color="isMT(message) ? 'primary' : 'default'" > From 5a4a594cc488baea109c9cc81ced8623b8e09d16 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 13:13:54 +0300 Subject: [PATCH 21/26] Fix with prettier --- api/pkg/services/message_service.go | 10 +++++----- api/pkg/validators/message_handler_validator.go | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 577c7970..131c3520 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -31,11 +31,11 @@ type ServiceAttachment struct { // 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 } diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go index 126f6a65..ee6cf9b2 100644 --- a/api/pkg/validators/message_handler_validator.go +++ b/api/pkg/validators/message_handler_validator.go @@ -48,8 +48,8 @@ func NewMessageHandlerValidator( } const ( - maxAttachmentCount = 10 - maxAttachmentSize = (3 * 1024 * 1024) / 2 // 1.5 MB per attachment + maxAttachmentCount = 10 + maxAttachmentSize = (3 * 1024 * 1024) / 2 // 1.5 MB per attachment maxTotalAttachmentSize = 3 * 1024 * 1024 // 3 MB total ) From 9528b392595bd39e830c7ec31a3f69cd7b1a562f Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 12 Apr 2026 14:54:27 +0300 Subject: [PATCH 22/26] Fix swagger --- api/docs/docs.go | 263 ++++++++++++++++++++++++- api/docs/swagger.json | 239 +++++++++++++++++++++- api/docs/swagger.yaml | 188 +++++++++++++++++- api/pkg/handlers/attachment_handler.go | 6 +- 4 files changed, 673 insertions(+), 23 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index 26545bdb..ab5a4ea6 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1,5 +1,4 @@ -// Package docs GENERATED BY SWAG; DO NOT EDIT -// This file was generated by swaggo/swag +// Package docs Code generated by swaggo/swag. DO NOT EDIT package docs import "github.com/swaggo/swag" @@ -1429,6 +1428,72 @@ const docTemplate = `{ } }, "/messages/{messageID}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a message from the database by the message ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "Get a message from the database.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message", + "name": "messageID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, "delete": { "security": [ { @@ -2390,6 +2455,13 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/requests.UserPaymentInvoice" } + }, + { + "type": "string", + "description": "ID of the subscription invoice to generate the PDF for", + "name": "subscriptionInvoiceID", + "in": "path", + "required": true } ], "responses": { @@ -2611,6 +2683,68 @@ const docTemplate = `{ } } }, + "/v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}": { + "get": { + "description": "Download an MMS attachment by its path components", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Attachments" + ], + "summary": "Download a message attachment", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "userID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Message ID", + "name": "messageID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Attachment index", + "name": "attachmentIndex", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Filename with extension", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/webhooks": { "get": { "security": [ @@ -3014,6 +3148,7 @@ const docTemplate = `{ "entities.Message": { "type": "object", "required": [ + "attachments", "contact", "content", "created_at", @@ -3031,6 +3166,16 @@ const docTemplate = `{ "user_id" ], "properties": { + "attachments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://example.com/image.jpg", + "https://example.com/video.mp4" + ] + }, "contact": { "type": "string", "example": "+18005550100" @@ -3118,7 +3263,11 @@ const docTemplate = `{ }, "sim": { "description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card", - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" + } + ], "example": "DEFAULT" }, "status": { @@ -3254,8 +3403,7 @@ const docTemplate = `{ "example": "+18005550199" }, "sim": { - "description": "SIM card that received the message", - "type": "string" + "$ref": "#/definitions/entities.SIM" }, "updated_at": { "type": "string", @@ -3331,6 +3479,46 @@ const docTemplate = `{ } } }, + "entities.SIM": { + "type": "string", + "enum": [ + "SIM1", + "SIM2" + ], + "x-enum-varnames": [ + "SIM1", + "SIM2" + ] + }, + "entities.SubscriptionName": { + "type": "string", + "enum": [ + "free", + "pro-monthly", + "pro-yearly", + "ultra-monthly", + "ultra-yearly", + "pro-lifetime", + "20k-monthly", + "100k-monthly", + "50k-monthly", + "200k-monthly", + "20k-yearly" + ], + "x-enum-varnames": [ + "SubscriptionNameFree", + "SubscriptionNameProMonthly", + "SubscriptionNameProYearly", + "SubscriptionNameUltraMonthly", + "SubscriptionNameUltraYearly", + "SubscriptionNameProLifetime", + "SubscriptionName20KMonthly", + "SubscriptionName100KMonthly", + "SubscriptionName50KMonthly", + "SubscriptionName200KMonthly", + "SubscriptionName20KYearly" + ] + }, "entities.User": { "type": "object", "required": [ @@ -3393,7 +3581,11 @@ const docTemplate = `{ "example": "8f9c71b8-b84e-4417-8408-a62274f65a08" }, "subscription_name": { - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SubscriptionName" + } + ], "example": "free" }, "subscription_renews_at": { @@ -3528,15 +3720,46 @@ const docTemplate = `{ } } }, + "requests.MessageAttachment": { + "type": "object", + "required": [ + "content", + "content_type", + "name" + ], + "properties": { + "content": { + "description": "Content is the base64-encoded attachment data", + "type": "string", + "example": "base64data..." + }, + "content_type": { + "description": "ContentType is the MIME type of the attachment", + "type": "string", + "example": "image/jpeg" + }, + "name": { + "description": "Name is the original filename of the attachment", + "type": "string", + "example": "photo.jpg" + } + } + }, "requests.MessageBulkSend": { "type": "object", "required": [ "content", - "encrypted", "from", "to" ], "properties": { + "attachments": { + "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS", + "type": "array", + "items": { + "type": "string" + } + }, "content": { "type": "string", "example": "This is a sample text message" @@ -3629,6 +3852,13 @@ const docTemplate = `{ "to" ], "properties": { + "attachments": { + "description": "Attachments is the list of MMS attachments received with the message", + "type": "array", + "items": { + "$ref": "#/definitions/requests.MessageAttachment" + } + }, "content": { "type": "string", "example": "This is a sample text message received on a phone" @@ -3644,7 +3874,11 @@ const docTemplate = `{ }, "sim": { "description": "SIM card that received the message", - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" + } + ], "example": "SIM1" }, "timestamp": { @@ -3666,6 +3900,17 @@ const docTemplate = `{ "to" ], "properties": { + "attachments": { + "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://example.com/image.jpg", + "https://example.com/video.mp4" + ] + }, "content": { "type": "string", "example": "This is a sample text message" @@ -4612,6 +4857,8 @@ var SwaggerInfo = &swag.Spec{ Description: "Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", } func init() { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index db16ea4b..b8bc5739 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1297,6 +1297,66 @@ } }, "/messages/{messageID}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get a message from the database by the message ID.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Messages"], + "summary": "Get a message from the database.", + "parameters": [ + { + "type": "string", + "default": "32343a19-da5e-4b1b-a767-3298a73703ca", + "description": "ID of the message", + "name": "messageID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/responses.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + }, "delete": { "security": [ { @@ -2166,6 +2226,13 @@ "schema": { "$ref": "#/definitions/requests.UserPaymentInvoice" } + }, + { + "type": "string", + "description": "ID of the subscription invoice to generate the PDF for", + "name": "subscriptionInvoiceID", + "in": "path", + "required": true } ], "responses": { @@ -2369,6 +2436,64 @@ } } }, + "/v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}": { + "get": { + "description": "Download an MMS attachment by its path components", + "produces": ["application/octet-stream"], + "tags": ["Attachments"], + "summary": "Download a message attachment", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "userID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Message ID", + "name": "messageID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Attachment index", + "name": "attachmentIndex", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Filename with extension", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/responses.NotFound" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/webhooks": { "get": { "security": [ @@ -2748,6 +2873,7 @@ "entities.Message": { "type": "object", "required": [ + "attachments", "contact", "content", "created_at", @@ -2765,6 +2891,16 @@ "user_id" ], "properties": { + "attachments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://example.com/image.jpg", + "https://example.com/video.mp4" + ] + }, "contact": { "type": "string", "example": "+18005550100" @@ -2852,7 +2988,11 @@ }, "sim": { "description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card", - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" + } + ], "example": "DEFAULT" }, "status": { @@ -2988,8 +3128,7 @@ "example": "+18005550199" }, "sim": { - "description": "SIM card that received the message", - "type": "string" + "$ref": "#/definitions/entities.SIM" }, "updated_at": { "type": "string", @@ -3062,6 +3201,40 @@ } } }, + "entities.SIM": { + "type": "string", + "enum": ["SIM1", "SIM2"], + "x-enum-varnames": ["SIM1", "SIM2"] + }, + "entities.SubscriptionName": { + "type": "string", + "enum": [ + "free", + "pro-monthly", + "pro-yearly", + "ultra-monthly", + "ultra-yearly", + "pro-lifetime", + "20k-monthly", + "100k-monthly", + "50k-monthly", + "200k-monthly", + "20k-yearly" + ], + "x-enum-varnames": [ + "SubscriptionNameFree", + "SubscriptionNameProMonthly", + "SubscriptionNameProYearly", + "SubscriptionNameUltraMonthly", + "SubscriptionNameUltraYearly", + "SubscriptionNameProLifetime", + "SubscriptionName20KMonthly", + "SubscriptionName100KMonthly", + "SubscriptionName50KMonthly", + "SubscriptionName200KMonthly", + "SubscriptionName20KYearly" + ] + }, "entities.User": { "type": "object", "required": [ @@ -3124,7 +3297,11 @@ "example": "8f9c71b8-b84e-4417-8408-a62274f65a08" }, "subscription_name": { - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SubscriptionName" + } + ], "example": "free" }, "subscription_renews_at": { @@ -3243,10 +3420,38 @@ } } }, + "requests.MessageAttachment": { + "type": "object", + "required": ["content", "content_type", "name"], + "properties": { + "content": { + "description": "Content is the base64-encoded attachment data", + "type": "string", + "example": "base64data..." + }, + "content_type": { + "description": "ContentType is the MIME type of the attachment", + "type": "string", + "example": "image/jpeg" + }, + "name": { + "description": "Name is the original filename of the attachment", + "type": "string", + "example": "photo.jpg" + } + } + }, "requests.MessageBulkSend": { "type": "object", - "required": ["content", "encrypted", "from", "to"], + "required": ["content", "from", "to"], "properties": { + "attachments": { + "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS", + "type": "array", + "items": { + "type": "string" + } + }, "content": { "type": "string", "example": "This is a sample text message" @@ -3320,6 +3525,13 @@ "type": "object", "required": ["content", "encrypted", "from", "sim", "timestamp", "to"], "properties": { + "attachments": { + "description": "Attachments is the list of MMS attachments received with the message", + "type": "array", + "items": { + "$ref": "#/definitions/requests.MessageAttachment" + } + }, "content": { "type": "string", "example": "This is a sample text message received on a phone" @@ -3335,7 +3547,11 @@ }, "sim": { "description": "SIM card that received the message", - "type": "string", + "allOf": [ + { + "$ref": "#/definitions/entities.SIM" + } + ], "example": "SIM1" }, "timestamp": { @@ -3353,6 +3569,17 @@ "type": "object", "required": ["content", "from", "to"], "properties": { + "attachments": { + "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://example.com/image.jpg", + "https://example.com/video.mp4" + ] + }, "content": { "type": "string", "example": "This is a sample text message" diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index e8b171eb..f5563f2a 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -102,6 +102,13 @@ definitions: type: object entities.Message: properties: + attachments: + example: + - https://example.com/image.jpg + - https://example.com/video.mp4 + items: + type: string + type: array contact: example: "+18005550100" type: string @@ -169,13 +176,14 @@ definitions: example: "2022-06-05T14:26:09.527976+03:00" type: string sim: + allOf: + - $ref: "#/definitions/entities.SIM" description: |- SIM is the SIM card to use to send the message * SMS1: use the SIM card in slot 1 * SMS2: use the SIM card in slot 2 * DEFAULT: used the default communication SIM card example: DEFAULT - type: string status: example: pending type: string @@ -189,6 +197,7 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: + - attachments - contact - content - created_at @@ -289,8 +298,7 @@ definitions: example: "+18005550199" type: string sim: - description: SIM card that received the message - type: string + $ref: "#/definitions/entities.SIM" updated_at: example: "2022-06-05T14:26:10.303278+03:00" type: string @@ -356,6 +364,40 @@ definitions: - user_email - user_id type: object + entities.SIM: + enum: + - SIM1 + - SIM2 + type: string + x-enum-varnames: + - SIM1 + - SIM2 + entities.SubscriptionName: + enum: + - free + - pro-monthly + - pro-yearly + - ultra-monthly + - ultra-yearly + - pro-lifetime + - 20k-monthly + - 100k-monthly + - 50k-monthly + - 200k-monthly + - 20k-yearly + type: string + x-enum-varnames: + - SubscriptionNameFree + - SubscriptionNameProMonthly + - SubscriptionNameProYearly + - SubscriptionNameUltraMonthly + - SubscriptionNameUltraYearly + - SubscriptionNameProLifetime + - SubscriptionName20KMonthly + - SubscriptionName100KMonthly + - SubscriptionName50KMonthly + - SubscriptionName200KMonthly + - SubscriptionName20KYearly entities.User: properties: active_phone_id: @@ -392,8 +434,9 @@ definitions: example: 8f9c71b8-b84e-4417-8408-a62274f65a08 type: string subscription_name: + allOf: + - $ref: "#/definitions/entities.SubscriptionName" example: free - type: string subscription_renews_at: example: "2022-06-05T14:26:02.302718+03:00" type: string @@ -501,8 +544,34 @@ definitions: - charging - phone_numbers type: object + requests.MessageAttachment: + properties: + content: + description: Content is the base64-encoded attachment data + example: base64data... + type: string + content_type: + description: ContentType is the MIME type of the attachment + example: image/jpeg + type: string + name: + description: Name is the original filename of the attachment + example: photo.jpg + type: string + required: + - content + - content_type + - name + type: object requests.MessageBulkSend: properties: + attachments: + description: + Attachments are optional. When you provide a list of attachments, + the message will be sent out as an MMS + items: + type: string + type: array content: example: This is a sample text message type: string @@ -530,7 +599,6 @@ definitions: type: array required: - content - - encrypted - from - to type: object @@ -580,6 +648,13 @@ definitions: type: object requests.MessageReceive: properties: + attachments: + description: + Attachments is the list of MMS attachments received with the + message + items: + $ref: "#/definitions/requests.MessageAttachment" + type: array content: example: This is a sample text message received on a phone type: string @@ -593,9 +668,10 @@ definitions: example: "+18005550199" type: string sim: + allOf: + - $ref: "#/definitions/entities.SIM" description: SIM card that received the message example: SIM1 - type: string timestamp: description: Timestamp is the time when the event was emitted, Please send @@ -615,6 +691,16 @@ definitions: type: object requests.MessageSend: properties: + attachments: + description: + Attachments are optional. When you provide a list of attachments, + the message will be sent out as an MMS + example: + - https://example.com/image.jpg + - https://example.com/video.mp4 + items: + type: string + type: array content: example: This is a sample text message type: string @@ -2031,6 +2117,49 @@ paths: summary: Delete a message from the database. tags: - Messages + get: + consumes: + - application/json + description: Get a message from the database by the message ID. + parameters: + - default: 32343a19-da5e-4b1b-a767-3298a73703ca + description: ID of the message + in: path + name: messageID + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + schema: + $ref: "#/definitions/responses.MessageResponse" + "400": + description: Bad Request + schema: + $ref: "#/definitions/responses.BadRequest" + "401": + description: Unauthorized + schema: + $ref: "#/definitions/responses.Unauthorized" + "404": + description: Not Found + schema: + $ref: "#/definitions/responses.NotFound" + "422": + description: Unprocessable Entity + schema: + $ref: "#/definitions/responses.UnprocessableEntity" + "500": + description: Internal Server Error + schema: + $ref: "#/definitions/responses.InternalServerError" + security: + - ApiKeyAuth: [] + summary: Get a message from the database. + tags: + - Messages /messages/{messageID}/events: post: consumes: @@ -2973,6 +3102,11 @@ paths: required: true schema: $ref: "#/definitions/requests.UserPaymentInvoice" + - description: ID of the subscription invoice to generate the PDF for + in: path + name: subscriptionInvoiceID + required: true + type: string produces: - application/pdf responses: @@ -3037,6 +3171,48 @@ paths: summary: Get the last 10 subscription payments. tags: - Users + /v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}: + get: + description: Download an MMS attachment by its path components + parameters: + - description: User ID + in: path + name: userID + required: true + type: string + - description: Message ID + in: path + name: messageID + required: true + type: string + - description: Attachment index + in: path + name: attachmentIndex + required: true + type: string + - description: Filename with extension + in: path + name: filename + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "404": + description: Not Found + schema: + $ref: "#/definitions/responses.NotFound" + "500": + description: Internal Server Error + schema: + $ref: "#/definitions/responses.InternalServerError" + summary: Download a message attachment + tags: + - Attachments /webhooks: get: consumes: diff --git a/api/pkg/handlers/attachment_handler.go b/api/pkg/handlers/attachment_handler.go index e6ea4f85..46a4397b 100644 --- a/api/pkg/handlers/attachment_handler.go +++ b/api/pkg/handlers/attachment_handler.go @@ -36,17 +36,17 @@ func (h *AttachmentHandler) RegisterRoutes(router fiber.Router) { router.Get("/v1/attachments/:userID/:messageID/:attachmentIndex/:filename", h.GetAttachment) } -// GetAttachment downloads an attachment +// GetAttachment Downloads an attachment // @Summary Download a message attachment // @Description Download an MMS attachment by its path components // @Tags Attachments -// @Produce octet-stream +// @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.NotFoundResponse +// @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 { From f85abf629e2eb0813cc5e196b13b3b47a4ff2722 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Tue, 14 Apr 2026 10:29:37 +0300 Subject: [PATCH 23/26] Fix display of the login page --- .../src/main/res/layout/activity_login.xml | 303 +++++++++--------- android/app/src/main/res/values/strings.xml | 2 +- android/build.gradle.kts | 4 +- 3 files changed, 161 insertions(+), 148 deletions(-) diff --git a/android/app/src/main/res/layout/activity_login.xml b/android/app/src/main/res/layout/activity_login.xml index 25a19468..03552be5 100644 --- a/android/app/src/main/res/layout/activity_login.xml +++ b/android/app/src/main/res/layout/activity_login.xml @@ -1,175 +1,188 @@ - - - - - - - + + - - + + + + - - + android:layout_marginTop="32dp" + android:layout_marginBottom="24dp" + android:autoLink="web" + android:lineHeight="28sp" + android:text="@string/get_your_api_key" + android:textAlignment="center" + android:textSize="20sp" + app:layout_constraintBottom_toTopOf="@+id/loginApiKeyTextInputLayout" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/imageView" + app:layout_constraintVertical_bias="0" + app:layout_constraintVertical_chainStyle="packed" /> - - - + android:hint="@string/text_area_api_key" + app:errorEnabled="true" + app:endIconMode="custom" + app:endIconDrawable="@android:drawable/ic_menu_camera" + app:endIconContentDescription="cameraButton" + app:layout_constraintBottom_toTopOf="@+id/loginPhoneNumberLayoutSIM1" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/textView"> - + - - - + + + android:layout_marginTop="8dp" + android:hint="@string/login_phone_number_sim1" + app:errorEnabled="true" + app:placeholderText="@string/login_phone_number_hint" + app:layout_constraintBottom_toTopOf="@+id/loginPhoneNumberLayoutSIM2" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/loginApiKeyTextInputLayout"> - + + + - + app:placeholderText="@string/login_phone_number_hint" + app:layout_constraintBottom_toTopOf="@+id/loginServerUrlLayoutContainer" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/loginPhoneNumberLayoutSIM1"> - - - - - - + + + + + + + + - + android:layout_marginTop="16dp" + android:orientation="vertical" + android:gravity="center" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/loginServerUrlLayoutContainer"> + + - + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 81a0f4f8..02dfa1b6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -7,7 +7,7 @@ Login With API Key API Key HTTP Sms Logo - Open\nhttpsms.com/settings\nto get your API key + Get Your API Key at\nhttpsms.com/settings Log Out e.g +18005550199 (international format) e.g https://api.httpsms.com diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 095d0884..32077a01 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -10,8 +10,8 @@ buildscript { } plugins { - id("com.android.application") version "9.1.0" apply false - id("com.android.library") version "9.1.0" apply false + id("com.android.application") version "9.1.1" apply false + id("com.android.library") version "9.1.1" apply false } tasks.register("clean") { From fb7e5c51f461cb94e198f0e149470631c457037b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:49:51 +0000 Subject: [PATCH 24/26] chore(deps-dev): bump axios from 0.30.3 to 0.31.0 in /web Bumps [axios](https://github.com/axios/axios) from 0.30.3 to 0.31.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.30.3...v0.31.0) --- updated-dependencies: - dependency-name: axios dependency-version: 0.31.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- web/package.json | 2 +- web/pnpm-lock.yaml | 130 +++++++++++++++++++++++++++------------------ 2 files changed, 80 insertions(+), 52 deletions(-) diff --git a/web/package.json b/web/package.json index 61cf0770..9eb11649 100644 --- a/web/package.json +++ b/web/package.json @@ -65,7 +65,7 @@ "@nuxtjs/vuetify": "^1.12.3", "@types/qrcode": "^1.5.6", "@vue/test-utils": "^1.3.6", - "axios": "^0.30.3", + "axios": "^0.31.0", "babel-core": "7.0.0-bridge.0", "babel-jest": "^30.2.0", "eslint": "^8.57.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7c170173..17babf5d 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -119,10 +119,10 @@ importers: version: 12.1.0(eslint@8.57.1)(typescript@4.9.5) '@nuxtjs/eslint-module': specifier: ^4.1.0 - version: 4.1.0(eslint@8.57.1)(rollup@3.29.5)(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1) + version: 4.1.0(eslint@8.57.1)(rollup@3.30.0)(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1) '@nuxtjs/stylelint-module': specifier: ^5.2.0 - version: 5.2.0(postcss@8.5.6)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1) + version: 5.2.0(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1) '@nuxtjs/vuetify': specifier: ^1.12.3 version: 1.12.3(vue@2.7.16)(webpack@5.104.1) @@ -133,8 +133,8 @@ importers: specifier: ^1.3.6 version: 1.3.6(vue-template-compiler@2.7.16)(vue@2.7.16) axios: - specifier: ^0.30.3 - version: 0.30.3 + specifier: ^0.31.0 + version: 0.31.0 babel-core: specifier: 7.0.0-bridge.0 version: 7.0.0-bridge.0(@babel/core@7.28.4) @@ -3009,8 +3009,8 @@ packages: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} - axios@0.30.3: - resolution: {integrity: sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==} + axios@0.31.0: + resolution: {integrity: sha512-HGIUj/P74co3rSLBV9SHz9LMgCmrXFEtkfMcC5r6bS5j3dBHUcAje2tS4fmU6WM20kuhvUX04XE58594dpgi1g==} babel-code-frame@6.26.0: resolution: {integrity: sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g==} @@ -3151,6 +3151,9 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + braces@2.3.2: resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==} engines: {node: '>=0.10.0'} @@ -4676,8 +4679,8 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} fill-range@4.0.0: resolution: {integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==} @@ -4745,8 +4748,8 @@ packages: flush-write-stream@1.1.1: resolution: {integrity: sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -4925,6 +4928,7 @@ packages: git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} + deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. hasBin: true git-up@7.0.0: @@ -6252,6 +6256,10 @@ packages: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.1: resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} engines: {node: '>=16 || 14 >=14.17'} @@ -7351,6 +7359,10 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -7694,8 +7706,8 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - rollup@3.29.5: - resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==} + rollup@3.30.0: + resolution: {integrity: sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} hasBin: true @@ -11783,9 +11795,9 @@ snapshots: node-html-parser: 6.1.13 ufo: 1.6.1 - '@nuxt/kit@3.12.2(rollup@3.29.5)': + '@nuxt/kit@3.12.2(rollup@3.30.0)': dependencies: - '@nuxt/schema': 3.12.2(rollup@3.29.5) + '@nuxt/schema': 3.12.2(rollup@3.30.0) c12: 1.11.1 consola: 3.2.3 defu: 6.1.4 @@ -11803,16 +11815,16 @@ snapshots: semver: 7.7.3 ufo: 1.6.1 unctx: 2.3.1 - unimport: 3.7.2(rollup@3.29.5) + unimport: 3.7.2(rollup@3.30.0) untyped: 1.4.2 transitivePeerDependencies: - magicast - rollup - supports-color - '@nuxt/kit@3.7.4(rollup@3.29.5)': + '@nuxt/kit@3.7.4(rollup@3.30.0)': dependencies: - '@nuxt/schema': 3.7.4(rollup@3.29.5) + '@nuxt/schema': 3.7.4(rollup@3.30.0) c12: 1.4.2 consola: 3.2.3 defu: 6.1.4 @@ -11828,7 +11840,7 @@ snapshots: semver: 7.7.3 ufo: 1.6.1 unctx: 2.3.1 - unimport: 3.4.0(rollup@3.29.5) + unimport: 3.4.0(rollup@3.30.0) untyped: 1.4.0 transitivePeerDependencies: - rollup @@ -11850,7 +11862,7 @@ snapshots: consola: 3.2.3 node-fetch-native: 1.6.7 - '@nuxt/schema@3.12.2(rollup@3.29.5)': + '@nuxt/schema@3.12.2(rollup@3.30.0)': dependencies: compatx: 0.1.8 consola: 3.2.3 @@ -11862,13 +11874,13 @@ snapshots: std-env: 3.7.0 ufo: 1.6.1 uncrypto: 0.1.3 - unimport: 3.7.2(rollup@3.29.5) + unimport: 3.7.2(rollup@3.30.0) untyped: 1.4.2 transitivePeerDependencies: - rollup - supports-color - '@nuxt/schema@3.7.4(rollup@3.29.5)': + '@nuxt/schema@3.7.4(rollup@3.30.0)': dependencies: '@nuxt/ui-templates': 1.3.1 consola: 3.2.3 @@ -11879,7 +11891,7 @@ snapshots: postcss-import-resolver: 2.0.0 std-env: 3.7.0 ufo: 1.6.1 - unimport: 3.7.2(rollup@3.29.5) + unimport: 3.7.2(rollup@3.30.0) untyped: 1.4.2 transitivePeerDependencies: - rollup @@ -12155,9 +12167,9 @@ snapshots: - eslint-import-resolver-webpack - supports-color - '@nuxtjs/eslint-module@4.1.0(eslint@8.57.1)(rollup@3.29.5)(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)': + '@nuxtjs/eslint-module@4.1.0(eslint@8.57.1)(rollup@3.30.0)(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)': dependencies: - '@nuxt/kit': 3.7.4(rollup@3.29.5) + '@nuxt/kit': 3.7.4(rollup@3.30.0) chokidar: 3.5.3 eslint: 8.57.1 eslint-webpack-plugin: 4.0.1(eslint@8.57.1)(webpack@5.104.1) @@ -12193,14 +12205,14 @@ snapshots: minimatch: 3.1.2 sitemap: 4.1.1 - '@nuxtjs/stylelint-module@5.2.0(postcss@8.5.6)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)': + '@nuxtjs/stylelint-module@5.2.0(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)': dependencies: - '@nuxt/kit': 3.12.2(rollup@3.29.5) + '@nuxt/kit': 3.12.2(rollup@3.30.0) chokidar: 3.6.0 pathe: 1.1.2 stylelint: 15.11.0(typescript@4.9.5) stylelint-webpack-plugin: 5.0.1(stylelint@15.11.0(typescript@4.9.5))(webpack@5.104.1) - vite-plugin-stylelint: 5.3.1(postcss@8.5.6)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1)) + vite-plugin-stylelint: 5.3.1(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1)) transitivePeerDependencies: - '@types/stylelint' - magicast @@ -12272,21 +12284,21 @@ snapshots: estree-walker: 2.0.2 picomatch: 2.3.1 - '@rollup/pluginutils@5.0.4(rollup@3.29.5)': + '@rollup/pluginutils@5.0.4(rollup@3.30.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 2.3.1 optionalDependencies: - rollup: 3.29.5 + rollup: 3.30.0 - '@rollup/pluginutils@5.1.0(rollup@3.29.5)': + '@rollup/pluginutils@5.1.0(rollup@3.30.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 2.3.1 optionalDependencies: - rollup: 3.29.5 + rollup: 3.30.0 '@sinclair/typebox@0.27.8': {} @@ -13251,9 +13263,9 @@ snapshots: available-typed-arrays@1.0.5: {} - axios@0.30.3: + axios@0.31.0: dependencies: - follow-redirects: 1.15.11 + follow-redirects: 1.16.0 form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -13474,6 +13486,11 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + optional: true + braces@2.3.2: dependencies: arr-flatten: 1.1.0 @@ -15196,9 +15213,9 @@ snapshots: file-uri-to-path@1.0.0: {} - filelist@1.0.4: + filelist@1.0.6: dependencies: - minimatch: 5.1.6 + minimatch: 5.1.9 optional: true fill-range@4.0.0: @@ -15332,7 +15349,7 @@ snapshots: inherits: 2.0.4 readable-stream: 2.3.8 - follow-redirects@1.15.11: {} + follow-redirects@1.16.0: {} for-each@0.3.3: dependencies: @@ -16260,7 +16277,7 @@ snapshots: jake@10.9.4: dependencies: async: 3.2.6 - filelist: 1.0.4 + filelist: 1.0.6 picocolors: 1.1.1 optional: true @@ -17178,6 +17195,11 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + optional: true + minimatch@9.0.1: dependencies: brace-expansion: 2.0.1 @@ -17558,7 +17580,7 @@ snapshots: dependencies: call-bind: 1.0.2 define-properties: 1.2.1 - has-symbols: 1.1.0 + has-symbols: 1.0.3 object-keys: 1.1.1 object.fromentries@2.0.7: @@ -18425,6 +18447,12 @@ snapshots: picocolors: 1.0.0 source-map-js: 1.0.2 + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -18821,7 +18849,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - rollup@3.29.5: + rollup@3.30.0: optionalDependencies: fsevents: 2.3.3 @@ -18845,7 +18873,7 @@ snapshots: dependencies: call-bind: 1.0.2 get-intrinsic: 1.3.0 - has-symbols: 1.1.0 + has-symbols: 1.0.3 isarray: 2.0.5 safe-buffer@5.1.2: {} @@ -19768,7 +19796,7 @@ snapshots: dependencies: call-bind: 1.0.2 has-bigints: 1.0.2 - has-symbols: 1.1.0 + has-symbols: 1.0.3 which-boxed-primitive: 1.0.2 uncrypto@0.1.3: {} @@ -19801,9 +19829,9 @@ snapshots: unicorn-magic@0.1.0: {} - unimport@3.4.0(rollup@3.29.5): + unimport@3.4.0(rollup@3.30.0): dependencies: - '@rollup/pluginutils': 5.0.4(rollup@3.29.5) + '@rollup/pluginutils': 5.0.4(rollup@3.30.0) escape-string-regexp: 5.0.0 fast-glob: 3.3.1 local-pkg: 0.4.3 @@ -19817,9 +19845,9 @@ snapshots: transitivePeerDependencies: - rollup - unimport@3.7.2(rollup@3.29.5): + unimport@3.7.2(rollup@3.30.0): dependencies: - '@rollup/pluginutils': 5.1.0(rollup@3.29.5) + '@rollup/pluginutils': 5.1.0(rollup@3.30.0) acorn: 8.15.0 escape-string-regexp: 5.0.0 estree-walker: 3.0.3 @@ -20004,24 +20032,24 @@ snapshots: rollup: 2.79.2 vite: 4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1) - vite-plugin-stylelint@5.3.1(postcss@8.5.6)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1)): + vite-plugin-stylelint@5.3.1(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1)): dependencies: - '@rollup/pluginutils': 5.1.0(rollup@3.29.5) + '@rollup/pluginutils': 5.1.0(rollup@3.30.0) chokidar: 3.6.0 debug: 4.4.1 stylelint: 15.11.0(typescript@4.9.5) vite: 4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1) optionalDependencies: postcss: 8.5.6 - rollup: 3.29.5 + rollup: 3.30.0 transitivePeerDependencies: - supports-color vite@4.5.3(@types/node@25.1.0)(sass@1.32.13)(terser@5.44.1): dependencies: esbuild: 0.18.20 - postcss: 8.5.6 - rollup: 3.29.5 + postcss: 8.5.10 + rollup: 3.30.0 optionalDependencies: '@types/node': 25.1.0 fsevents: 2.3.3 @@ -20413,7 +20441,7 @@ snapshots: available-typed-arrays: 1.0.5 call-bind: 1.0.2 for-each: 0.3.3 - gopd: 1.2.0 + gopd: 1.0.1 has-tostringtag: 1.0.2 which@1.3.1: From 07eea2382c0ec66ab0c29f11f3d526b6ed9602fe Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Fri, 17 Apr 2026 09:40:49 +0300 Subject: [PATCH 25/26] Add try/catch block for heartbeat sending --- .../main/java/com/httpsms/worker/HeartbeatWorker.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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() } From 0bab167e85e836c7fc5707c6a4dbf7b6c1d5b36c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:05:38 +0000 Subject: [PATCH 26/26] fix(deps): bump github.com/jackc/pgx/v5 from 5.9.1 to 5.9.2 in /api Bumps [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) from 5.9.1 to 5.9.2. - [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md) - [Commits](https://github.com/jackc/pgx/compare/v5.9.1...v5.9.2) --- updated-dependencies: - dependency-name: github.com/jackc/pgx/v5 dependency-version: 5.9.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- api/go.mod | 2 +- api/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/go.mod b/api/go.mod index c7073253..3c361930 100644 --- a/api/go.mod +++ b/api/go.mod @@ -134,7 +134,7 @@ require ( github.com/huandu/xstrings v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.9.1 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/api/go.sum b/api/go.sum index 9156143b..60df8733 100644 --- a/api/go.sum +++ b/api/go.sum @@ -213,8 +213,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= -github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jaswdr/faker/v2 v2.9.1 h1:J0Rjqb2/FquZnoZplzkGVL5LmhNkeIpvsSMoJKzn+8E=