From 727df631079c994b3d199327fcd06b6dc575e6db Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sat, 11 Apr 2026 11:19:24 +0300 Subject: [PATCH 01/20] 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 000000000..7b153b60b --- /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 02/20] 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 000000000..a759e1c48 --- /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 7b153b60b..7beea85f7 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 03/20] 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 c7dca2312..a667882b6 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 81931b9a6..692efb3a5 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 04/20] 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 94ce01462..cd9e6801d 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 f55f2c017..3f908ec55 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 05/20] 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 000000000..843d56a5e --- /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 000000000..0512425f3 --- /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 06/20] 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 abe3a014c..04dd6c2ed 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 f592761c7..be405101c 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 a667882b6..efddaee8c 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 07/20] 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 000000000..930478875 --- /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 08/20] 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 000000000..770f7aa9c --- /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 09/20] 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 9a63886bb..7bec234df 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 10/20] 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 000000000..ff2950f32 --- /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 11/20] 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 efddaee8c..381885482 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 12/20] 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 2e6be8fb8..5441d7fea 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 14fc9ea49..86fa93478 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 13/20] 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 ff2950f32..75406f348 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 843d56a5e..b7b7bf4af 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 930478875..602c25096 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 381885482..8ed4a1f32 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 14/20] 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 cd9e6801d..c70732535 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 3f908ec55..9156143b2 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 86fa93478..de945d6cd 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 75406f348..e6ea4f85c 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 b7b7bf4af..cbd77c12a 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 770f7aa9c..d1e0eb926 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 000000000..65eadf2fa --- /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 602c25096..000000000 --- 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 be405101c..b89cddfa9 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 8ed4a1f32..c661da9ec 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 15/20] 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 16/20] 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 cbd77c12a..f5540cb53 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 0512425f3..76cc5adc9 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 7bec234df..126f6a655 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 17/20] 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 f5540cb53..11d80e206 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 76cc5adc9..1b29fa68b 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 18/20] 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 c661da9ec..577c79703 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 19/20] 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 de945d6cd..33d27b01b 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 692efb3a5..fc0e27f32 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 20/20] 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 577c79703..131c35203 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 126f6a655..ee6cf9b27 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 )