Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
727df63
docs: add MMS attachment support design spec
AchoArnold Apr 11, 2026
14af0a1
docs: add MMS attachments implementation plan
AchoArnold Apr 11, 2026
ec48ea0
feat: display short attachment names in thread view and fix typo
AchoArnold Apr 11, 2026
722abc1
chore: add cloud.google.com/go/storage and golang.org/x/sync deps
AchoArnold Apr 11, 2026
22fcb13
feat: add AttachmentStorage interface and content-type utilities
AchoArnold Apr 11, 2026
578c0de
feat: add attachment fields to request, params, and event structs
AchoArnold Apr 11, 2026
ee01c32
feat: add MemoryAttachmentStorage implementation
AchoArnold Apr 11, 2026
38a1175
feat: add GCSAttachmentStorage implementation
AchoArnold Apr 11, 2026
4cea63e
feat: add attachment count, size, and content-type validation
AchoArnold Apr 11, 2026
c21245b
feat: add AttachmentHandler for downloading attachments
AchoArnold Apr 11, 2026
0d7f385
feat: add attachment upload logic to MessageService.ReceiveMessage()
AchoArnold Apr 11, 2026
082eebf
feat: wire attachment storage and handler in DI container
AchoArnold Apr 11, 2026
3201494
fix: address code review findings for MMS attachments
AchoArnold Apr 11, 2026
b79a3e0
refactor: address PR review feedback - rename AttachmentStorage to At…
AchoArnold Apr 11, 2026
a35f8a3
rename: gcs_attachment_repository.go -> google_cloud_storage_attachme…
AchoArnold Apr 11, 2026
556234b
fix: address second round of PR review feedback
AchoArnold Apr 11, 2026
4158509
fix: allow A-Z, underscore, and dash in SanitizeFilename
AchoArnold Apr 11, 2026
de3d99f
fix: simplify uploadAttachments call with := and add user ID to logs
AchoArnold Apr 11, 2026
df1083f
Fix auth
AchoArnold Apr 11, 2026
5a4a594
Fix with prettier
AchoArnold Apr 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/.env.docker
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
4 changes: 2 additions & 2 deletions api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -80,7 +82,6 @@ require (
cloud.google.com/go/iam v1.7.0 // indirect
cloud.google.com/go/longrunning v0.9.0 // indirect
cloud.google.com/go/monitoring v1.25.0 // indirect
cloud.google.com/go/storage v1.61.3 // indirect
cloud.google.com/go/trace v1.12.0 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions api/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8
cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E=
cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPkW7NK4=
cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc=
cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg=
cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=
cloud.google.com/go/storage v1.62.0 h1:w2pQJhpUqVerMON45vatE2FpCYsNTf7OHjkn6ux5mMU=
cloud.google.com/go/storage v1.62.0/go.mod h1:T5hz3qzcpnxZ5LdKc7y8Tw7lh4v9zeeVyrD/cLJAzZU=
cloud.google.com/go/trace v1.12.0 h1:XvWHYfr9q88cX4pZyou6qCcSagnuASyUq2ej1dB6NzQ=
cloud.google.com/go/trace v1.12.0/go.mod h1:TOYfyeoyCGsSH0ifXD6Aius24uQI9xV3RyvOdljFIyg=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
Expand Down Expand Up @@ -375,8 +375,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bT
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
Expand Down
73 changes: 65 additions & 8 deletions api/pkg/di/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -80,13 +81,14 @@ import (

// Container is used to resolve services at runtime
type Container struct {
projectID string
db *gorm.DB
dedicatedDB *gorm.DB
version string
app *fiber.App
eventDispatcher *services.EventDispatcher
logger telemetry.Logger
projectID string
db *gorm.DB
dedicatedDB *gorm.DB
version string
app *fiber.App
eventDispatcher *services.EventDispatcher
logger telemetry.Logger
attachmentRepository repositories.AttachmentRepository
}

// NewLiteContainer creates a Container without any routes or listeners
Expand Down Expand Up @@ -118,6 +120,7 @@ func NewContainer(projectID string, version string) (container *Container) {

container.RegisterMessageListeners()
container.RegisterMessageRoutes()
container.RegisterAttachmentRoutes()
container.RegisterBulkMessageRoutes()

container.RegisterMessageThreadRoutes()
Expand Down Expand Up @@ -395,7 +398,7 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (
// FirebaseApp creates a new instance of firebase.App
func (container *Container) FirebaseApp() (app *firebase.App) {
container.logger.Debug(fmt.Sprintf("creating %T", app))
app, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsJSON(container.FirebaseCredentials()))
app, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsJSON(option.ServiceAccount, container.FirebaseCredentials()))
if err != nil {
msg := "cannot initialize firebase application"
container.logger.Fatal(stacktrace.Propagate(err, msg))
Expand Down Expand Up @@ -1430,9 +1433,63 @@ func (container *Container) MessageService() (service *services.MessageService)
container.MessageRepository(),
container.EventDispatcher(),
container.PhoneService(),
container.AttachmentRepository(),
container.APIBaseURL(),
)
}

// AttachmentRepository creates a cached AttachmentRepository based on configuration
func (container *Container) AttachmentRepository() repositories.AttachmentRepository {
if container.attachmentRepository != nil {
return container.attachmentRepository
}

bucket := os.Getenv("GCS_BUCKET_NAME")
if bucket != "" {
container.logger.Debug("creating GoogleCloudStorageAttachmentRepository")
client, err := storage.NewClient(context.Background(), option.WithAuthCredentialsJSON(option.ServiceAccount, container.FirebaseCredentials()))
if err != nil {
container.logger.Fatal(stacktrace.Propagate(err, "cannot create GCS client"))
}
container.attachmentRepository = repositories.NewGoogleCloudStorageAttachmentRepository(
container.Logger(),
container.Tracer(),
client,
bucket,
)
} else {
container.logger.Debug("creating MemoryAttachmentRepository (GCS_BUCKET_NAME not set)")
container.attachmentRepository = repositories.NewMemoryAttachmentRepository(
container.Logger(),
container.Tracer(),
)
}

return container.attachmentRepository
}

// APIBaseURL returns the API base URL derived from EVENTS_QUEUE_ENDPOINT
func (container *Container) APIBaseURL() string {
endpoint := os.Getenv("EVENTS_QUEUE_ENDPOINT")
return strings.TrimSuffix(endpoint, "/v1/events")
}
Comment thread
AchoArnold marked this conversation as resolved.

// AttachmentHandler creates a new AttachmentHandler
func (container *Container) AttachmentHandler() (handler *handlers.AttachmentHandler) {
container.logger.Debug(fmt.Sprintf("creating %T", handler))
return handlers.NewAttachmentHandler(
container.Logger(),
container.Tracer(),
container.AttachmentRepository(),
)
}

// RegisterAttachmentRoutes registers routes for the /attachments prefix
func (container *Container) RegisterAttachmentRoutes() {
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.AttachmentHandler{}))
container.AttachmentHandler().RegisterRoutes(container.App())
}

// PhoneAPIKeyService creates a new instance of services.PhoneAPIKeyService
func (container *Container) PhoneAPIKeyService() (service *services.PhoneAPIKeyService) {
container.logger.Debug(fmt.Sprintf("creating %T", service))
Expand Down
17 changes: 9 additions & 8 deletions api/pkg/events/message_phone_received_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
85 changes: 85 additions & 0 deletions api/pkg/handlers/attachment_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package handlers

import (
"fmt"
"path/filepath"

"github.com/NdoleStudio/httpsms/pkg/repositories"
"github.com/NdoleStudio/httpsms/pkg/telemetry"
"github.com/gofiber/fiber/v2"
"github.com/palantir/stacktrace"
)

// AttachmentHandler handles attachment download requests
type AttachmentHandler struct {
handler
logger telemetry.Logger
tracer telemetry.Tracer
storage repositories.AttachmentRepository
}

// NewAttachmentHandler creates a new AttachmentHandler
func NewAttachmentHandler(
logger telemetry.Logger,
tracer telemetry.Tracer,
storage repositories.AttachmentRepository,
) (h *AttachmentHandler) {
return &AttachmentHandler{
logger: logger.WithService(fmt.Sprintf("%T", h)),
tracer: tracer,
storage: storage,
}
}

// RegisterRoutes registers the routes for the AttachmentHandler (no auth middleware — public endpoint)
func (h *AttachmentHandler) RegisterRoutes(router fiber.Router) {
router.Get("/v1/attachments/:userID/:messageID/:attachmentIndex/:filename", h.GetAttachment)
}

// GetAttachment downloads an attachment
// @Summary Download a message attachment
// @Description Download an MMS attachment by its path components
// @Tags Attachments
// @Produce 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 /v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename} [get]
func (h *AttachmentHandler) GetAttachment(c *fiber.Ctx) error {
ctx, span := h.tracer.StartFromFiberCtx(c)
defer span.End()

ctxLogger := h.tracer.CtxLogger(h.logger, span)

userID := c.Params("userID")
messageID := c.Params("messageID")
attachmentIndex := c.Params("attachmentIndex")
filename := c.Params("filename")

path := fmt.Sprintf("attachments/%s/%s/%s/%s", userID, messageID, attachmentIndex, filename)

ctxLogger.Info(fmt.Sprintf("downloading attachment from path [%s]", path))

data, err := h.storage.Download(ctx, path)
if err != nil {
msg := fmt.Sprintf("cannot download attachment from path [%s]", path)
ctxLogger.Warn(stacktrace.Propagate(err, msg))
if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
return h.responseNotFound(c, "attachment not found")
}
return h.responseInternalServerError(c)
}

ext := filepath.Ext(filename)
contentType := repositories.ContentTypeFromExtension(ext)

c.Set("Content-Type", contentType)
c.Set("Content-Disposition", "attachment")
c.Set("X-Content-Type-Options", "nosniff")

return c.Send(data)
}
99 changes: 99 additions & 0 deletions api/pkg/repositories/attachment_repository.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package repositories

import (
"context"
"fmt"
"path/filepath"
"strings"
)

// AttachmentRepository is the interface for storing and retrieving message attachments
type AttachmentRepository interface {
// Upload stores attachment data at the given path with the specified content type
Upload(ctx context.Context, path string, data []byte, contentType string) error
// Download retrieves attachment data from the given path
Download(ctx context.Context, path string) ([]byte, error)
// Delete removes an attachment at the given path
Delete(ctx context.Context, path string) error
}

// contentTypeExtensions maps MIME types to file extensions
var contentTypeExtensions = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"video/mp4": ".mp4",
"video/3gpp": ".3gp",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/amr": ".amr",
"application/pdf": ".pdf",
"text/vcard": ".vcf",
"text/x-vcard": ".vcf",
}

// extensionContentTypes is the reverse map from file extensions to canonical MIME types
var extensionContentTypes = map[string]string{
".jpg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".mp4": "video/mp4",
".3gp": "video/3gpp",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".amr": "audio/amr",
".pdf": "application/pdf",
".vcf": "text/vcard",
}

// AllowedContentTypes returns the set of allowed MIME types for attachments
func AllowedContentTypes() map[string]bool {
allowed := make(map[string]bool, len(contentTypeExtensions))
for ct := range contentTypeExtensions {
allowed[ct] = true
}
return allowed
}

// ExtensionFromContentType returns the file extension for a MIME content type.
// Returns ".bin" if the content type is not recognized.
func ExtensionFromContentType(contentType string) string {
if ext, ok := contentTypeExtensions[contentType]; ok {
return ext
}
return ".bin"
}

// ContentTypeFromExtension returns the MIME content type for a file extension.
// Returns "application/octet-stream" if the extension is not recognized.
func ContentTypeFromExtension(ext string) string {
if ct, ok := extensionContentTypes[ext]; ok {
return ct
}
return "application/octet-stream"
}

// SanitizeFilename removes path separators and traversal sequences from a filename.
// Returns "attachment-{index}" if the sanitized name is empty.
func SanitizeFilename(name string, index int) string {
name = strings.TrimSuffix(name, filepath.Ext(name))

var builder strings.Builder
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
builder.WriteRune(r)
} else if r == ' ' {
builder.WriteRune('-')
}
}
name = strings.Trim(builder.String(), "-")

if name == "" {
return fmt.Sprintf("attachment-%d", index)
}
return name
}
Comment on lines +80 to +99
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SanitizeFilename currently only strips path separators, "..", and surrounding whitespace, but it can still return names containing URL-reserved characters (spaces, #, ?, %, etc.) and non-printable characters. Since the sanitized name is embedded directly into the generated download URL path (without PathEscape), this can produce invalid or ambiguous URLs. Consider normalizing to a URL-safe character set (e.g., replace anything outside [A-Za-z0-9.-] with '', drop control chars) and/or URL-escaping when constructing the download URL.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When sanitizing the name. stripe all characters ecelpt alpha numeric a-z 0-9 and replace space chracter by -

Loading
Loading