Skip to content

Commit cbf878d

Browse files
authored
Merge pull request #854 from NdoleStudio/feat/mms-attachment-support
feat: add MMS attachment upload/download support
2 parents fdcf11f + 5a4a594 commit cbf878d

16 files changed

Lines changed: 2046 additions & 64 deletions

api/.env.docker

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ DATABASE_URL_DEDICATED=postgresql://dbusername:dbpassword@postgres:5432/httpsms
4848
# Redis connection string
4949
REDIS_URL=redis://@redis:6379
5050

51+
# Google Cloud Storage bucket for MMS attachments. Leave empty to use in-memory storage.
52+
GCS_BUCKET_NAME=
53+
5154
# [optional] If you would like to use uptrace.dev for distributed tracing, you can set the DSN here.
5255
# This is optional and you can leave it empty if you don't want to use uptrace
5356
UPTRACE_DSN=

api/go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.25.0
44

55
require (
66
cloud.google.com/go/cloudtasks v1.14.0
7+
cloud.google.com/go/storage v1.62.0
78
firebase.google.com/go v3.13.0+incompatible
89
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0
910
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.31.0
@@ -50,6 +51,7 @@ require (
5051
go.opentelemetry.io/otel/sdk v1.43.0
5152
go.opentelemetry.io/otel/sdk/metric v1.43.0
5253
go.opentelemetry.io/otel/trace v1.43.0
54+
golang.org/x/sync v0.20.0
5355
google.golang.org/api v0.274.0
5456
google.golang.org/protobuf v1.36.11
5557
gorm.io/driver/postgres v1.6.0
@@ -80,7 +82,6 @@ require (
8082
cloud.google.com/go/iam v1.7.0 // indirect
8183
cloud.google.com/go/longrunning v0.9.0 // indirect
8284
cloud.google.com/go/monitoring v1.25.0 // indirect
83-
cloud.google.com/go/storage v1.61.3 // indirect
8485
cloud.google.com/go/trace v1.12.0 // indirect
8586
dario.cat/mergo v1.0.2 // indirect
8687
filippo.io/edwards25519 v1.2.0 // indirect
@@ -190,7 +191,6 @@ require (
190191
golang.org/x/mod v0.34.0 // indirect
191192
golang.org/x/net v0.52.0 // indirect
192193
golang.org/x/oauth2 v0.36.0 // indirect
193-
golang.org/x/sync v0.20.0 // indirect
194194
golang.org/x/sys v0.42.0 // indirect
195195
golang.org/x/text v0.35.0 // indirect
196196
golang.org/x/time v0.15.0 // indirect

api/go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8
2222
cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E=
2323
cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPkW7NK4=
2424
cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc=
25-
cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg=
26-
cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=
25+
cloud.google.com/go/storage v1.62.0 h1:w2pQJhpUqVerMON45vatE2FpCYsNTf7OHjkn6ux5mMU=
26+
cloud.google.com/go/storage v1.62.0/go.mod h1:T5hz3qzcpnxZ5LdKc7y8Tw7lh4v9zeeVyrD/cLJAzZU=
2727
cloud.google.com/go/trace v1.12.0 h1:XvWHYfr9q88cX4pZyou6qCcSagnuASyUq2ej1dB6NzQ=
2828
cloud.google.com/go/trace v1.12.0/go.mod h1:TOYfyeoyCGsSH0ifXD6Aius24uQI9xV3RyvOdljFIyg=
2929
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
@@ -375,8 +375,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bT
375375
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
376376
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
377377
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
378-
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
379-
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
378+
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
379+
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
380380
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
381381
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
382382
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=

api/pkg/di/container.go

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525

2626
"github.com/NdoleStudio/httpsms/pkg/discord"
2727

28+
"cloud.google.com/go/storage"
2829
mexporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric"
2930
cloudtrace "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace"
3031
"github.com/NdoleStudio/httpsms/pkg/cache"
@@ -80,13 +81,14 @@ import (
8081

8182
// Container is used to resolve services at runtime
8283
type Container struct {
83-
projectID string
84-
db *gorm.DB
85-
dedicatedDB *gorm.DB
86-
version string
87-
app *fiber.App
88-
eventDispatcher *services.EventDispatcher
89-
logger telemetry.Logger
84+
projectID string
85+
db *gorm.DB
86+
dedicatedDB *gorm.DB
87+
version string
88+
app *fiber.App
89+
eventDispatcher *services.EventDispatcher
90+
logger telemetry.Logger
91+
attachmentRepository repositories.AttachmentRepository
9092
}
9193

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

119121
container.RegisterMessageListeners()
120122
container.RegisterMessageRoutes()
123+
container.RegisterAttachmentRoutes()
121124
container.RegisterBulkMessageRoutes()
122125

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

1441+
// AttachmentRepository creates a cached AttachmentRepository based on configuration
1442+
func (container *Container) AttachmentRepository() repositories.AttachmentRepository {
1443+
if container.attachmentRepository != nil {
1444+
return container.attachmentRepository
1445+
}
1446+
1447+
bucket := os.Getenv("GCS_BUCKET_NAME")
1448+
if bucket != "" {
1449+
container.logger.Debug("creating GoogleCloudStorageAttachmentRepository")
1450+
client, err := storage.NewClient(context.Background(), option.WithAuthCredentialsJSON(option.ServiceAccount, container.FirebaseCredentials()))
1451+
if err != nil {
1452+
container.logger.Fatal(stacktrace.Propagate(err, "cannot create GCS client"))
1453+
}
1454+
container.attachmentRepository = repositories.NewGoogleCloudStorageAttachmentRepository(
1455+
container.Logger(),
1456+
container.Tracer(),
1457+
client,
1458+
bucket,
1459+
)
1460+
} else {
1461+
container.logger.Debug("creating MemoryAttachmentRepository (GCS_BUCKET_NAME not set)")
1462+
container.attachmentRepository = repositories.NewMemoryAttachmentRepository(
1463+
container.Logger(),
1464+
container.Tracer(),
1465+
)
1466+
}
1467+
1468+
return container.attachmentRepository
1469+
}
1470+
1471+
// APIBaseURL returns the API base URL derived from EVENTS_QUEUE_ENDPOINT
1472+
func (container *Container) APIBaseURL() string {
1473+
endpoint := os.Getenv("EVENTS_QUEUE_ENDPOINT")
1474+
return strings.TrimSuffix(endpoint, "/v1/events")
1475+
}
1476+
1477+
// AttachmentHandler creates a new AttachmentHandler
1478+
func (container *Container) AttachmentHandler() (handler *handlers.AttachmentHandler) {
1479+
container.logger.Debug(fmt.Sprintf("creating %T", handler))
1480+
return handlers.NewAttachmentHandler(
1481+
container.Logger(),
1482+
container.Tracer(),
1483+
container.AttachmentRepository(),
1484+
)
1485+
}
1486+
1487+
// RegisterAttachmentRoutes registers routes for the /attachments prefix
1488+
func (container *Container) RegisterAttachmentRoutes() {
1489+
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.AttachmentHandler{}))
1490+
container.AttachmentHandler().RegisterRoutes(container.App())
1491+
}
1492+
14361493
// PhoneAPIKeyService creates a new instance of services.PhoneAPIKeyService
14371494
func (container *Container) PhoneAPIKeyService() (service *services.PhoneAPIKeyService) {
14381495
container.logger.Debug(fmt.Sprintf("creating %T", service))

api/pkg/events/message_phone_received_event.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ const EventTypeMessagePhoneReceived = "message.phone.received"
1313

1414
// MessagePhoneReceivedPayload is the payload of the EventTypeMessagePhoneReceived event
1515
type MessagePhoneReceivedPayload struct {
16-
MessageID uuid.UUID `json:"message_id"`
17-
UserID entities.UserID `json:"user_id"`
18-
Owner string `json:"owner"`
19-
Encrypted bool `json:"encrypted"`
20-
Contact string `json:"contact"`
21-
Timestamp time.Time `json:"timestamp"`
22-
Content string `json:"content"`
23-
SIM entities.SIM `json:"sim"`
16+
MessageID uuid.UUID `json:"message_id"`
17+
UserID entities.UserID `json:"user_id"`
18+
Owner string `json:"owner"`
19+
Encrypted bool `json:"encrypted"`
20+
Contact string `json:"contact"`
21+
Timestamp time.Time `json:"timestamp"`
22+
Content string `json:"content"`
23+
SIM entities.SIM `json:"sim"`
24+
Attachments []string `json:"attachments"`
2425
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package handlers
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
7+
"github.com/NdoleStudio/httpsms/pkg/repositories"
8+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
9+
"github.com/gofiber/fiber/v2"
10+
"github.com/palantir/stacktrace"
11+
)
12+
13+
// AttachmentHandler handles attachment download requests
14+
type AttachmentHandler struct {
15+
handler
16+
logger telemetry.Logger
17+
tracer telemetry.Tracer
18+
storage repositories.AttachmentRepository
19+
}
20+
21+
// NewAttachmentHandler creates a new AttachmentHandler
22+
func NewAttachmentHandler(
23+
logger telemetry.Logger,
24+
tracer telemetry.Tracer,
25+
storage repositories.AttachmentRepository,
26+
) (h *AttachmentHandler) {
27+
return &AttachmentHandler{
28+
logger: logger.WithService(fmt.Sprintf("%T", h)),
29+
tracer: tracer,
30+
storage: storage,
31+
}
32+
}
33+
34+
// RegisterRoutes registers the routes for the AttachmentHandler (no auth middleware — public endpoint)
35+
func (h *AttachmentHandler) RegisterRoutes(router fiber.Router) {
36+
router.Get("/v1/attachments/:userID/:messageID/:attachmentIndex/:filename", h.GetAttachment)
37+
}
38+
39+
// GetAttachment downloads an attachment
40+
// @Summary Download a message attachment
41+
// @Description Download an MMS attachment by its path components
42+
// @Tags Attachments
43+
// @Produce octet-stream
44+
// @Param userID path string true "User ID"
45+
// @Param messageID path string true "Message ID"
46+
// @Param attachmentIndex path string true "Attachment index"
47+
// @Param filename path string true "Filename with extension"
48+
// @Success 200 {file} binary
49+
// @Failure 404 {object} responses.NotFoundResponse
50+
// @Failure 500 {object} responses.InternalServerError
51+
// @Router /v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename} [get]
52+
func (h *AttachmentHandler) GetAttachment(c *fiber.Ctx) error {
53+
ctx, span := h.tracer.StartFromFiberCtx(c)
54+
defer span.End()
55+
56+
ctxLogger := h.tracer.CtxLogger(h.logger, span)
57+
58+
userID := c.Params("userID")
59+
messageID := c.Params("messageID")
60+
attachmentIndex := c.Params("attachmentIndex")
61+
filename := c.Params("filename")
62+
63+
path := fmt.Sprintf("attachments/%s/%s/%s/%s", userID, messageID, attachmentIndex, filename)
64+
65+
ctxLogger.Info(fmt.Sprintf("downloading attachment from path [%s]", path))
66+
67+
data, err := h.storage.Download(ctx, path)
68+
if err != nil {
69+
msg := fmt.Sprintf("cannot download attachment from path [%s]", path)
70+
ctxLogger.Warn(stacktrace.Propagate(err, msg))
71+
if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
72+
return h.responseNotFound(c, "attachment not found")
73+
}
74+
return h.responseInternalServerError(c)
75+
}
76+
77+
ext := filepath.Ext(filename)
78+
contentType := repositories.ContentTypeFromExtension(ext)
79+
80+
c.Set("Content-Type", contentType)
81+
c.Set("Content-Disposition", "attachment")
82+
c.Set("X-Content-Type-Options", "nosniff")
83+
84+
return c.Send(data)
85+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package repositories
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
// AttachmentRepository is the interface for storing and retrieving message attachments
11+
type AttachmentRepository interface {
12+
// Upload stores attachment data at the given path with the specified content type
13+
Upload(ctx context.Context, path string, data []byte, contentType string) error
14+
// Download retrieves attachment data from the given path
15+
Download(ctx context.Context, path string) ([]byte, error)
16+
// Delete removes an attachment at the given path
17+
Delete(ctx context.Context, path string) error
18+
}
19+
20+
// contentTypeExtensions maps MIME types to file extensions
21+
var contentTypeExtensions = map[string]string{
22+
"image/jpeg": ".jpg",
23+
"image/png": ".png",
24+
"image/gif": ".gif",
25+
"image/webp": ".webp",
26+
"image/bmp": ".bmp",
27+
"video/mp4": ".mp4",
28+
"video/3gpp": ".3gp",
29+
"audio/mpeg": ".mp3",
30+
"audio/ogg": ".ogg",
31+
"audio/amr": ".amr",
32+
"application/pdf": ".pdf",
33+
"text/vcard": ".vcf",
34+
"text/x-vcard": ".vcf",
35+
}
36+
37+
// extensionContentTypes is the reverse map from file extensions to canonical MIME types
38+
var extensionContentTypes = map[string]string{
39+
".jpg": "image/jpeg",
40+
".png": "image/png",
41+
".gif": "image/gif",
42+
".webp": "image/webp",
43+
".bmp": "image/bmp",
44+
".mp4": "video/mp4",
45+
".3gp": "video/3gpp",
46+
".mp3": "audio/mpeg",
47+
".ogg": "audio/ogg",
48+
".amr": "audio/amr",
49+
".pdf": "application/pdf",
50+
".vcf": "text/vcard",
51+
}
52+
53+
// AllowedContentTypes returns the set of allowed MIME types for attachments
54+
func AllowedContentTypes() map[string]bool {
55+
allowed := make(map[string]bool, len(contentTypeExtensions))
56+
for ct := range contentTypeExtensions {
57+
allowed[ct] = true
58+
}
59+
return allowed
60+
}
61+
62+
// ExtensionFromContentType returns the file extension for a MIME content type.
63+
// Returns ".bin" if the content type is not recognized.
64+
func ExtensionFromContentType(contentType string) string {
65+
if ext, ok := contentTypeExtensions[contentType]; ok {
66+
return ext
67+
}
68+
return ".bin"
69+
}
70+
71+
// ContentTypeFromExtension returns the MIME content type for a file extension.
72+
// Returns "application/octet-stream" if the extension is not recognized.
73+
func ContentTypeFromExtension(ext string) string {
74+
if ct, ok := extensionContentTypes[ext]; ok {
75+
return ct
76+
}
77+
return "application/octet-stream"
78+
}
79+
80+
// SanitizeFilename removes path separators and traversal sequences from a filename.
81+
// Returns "attachment-{index}" if the sanitized name is empty.
82+
func SanitizeFilename(name string, index int) string {
83+
name = strings.TrimSuffix(name, filepath.Ext(name))
84+
85+
var builder strings.Builder
86+
for _, r := range name {
87+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
88+
builder.WriteRune(r)
89+
} else if r == ' ' {
90+
builder.WriteRune('-')
91+
}
92+
}
93+
name = strings.Trim(builder.String(), "-")
94+
95+
if name == "" {
96+
return fmt.Sprintf("attachment-%d", index)
97+
}
98+
return name
99+
}

0 commit comments

Comments
 (0)