-
-
Notifications
You must be signed in to change notification settings - Fork 296
feat: add MMS attachment upload/download support #854
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
727df63
14af0a1
ec48ea0
722abc1
22fcb13
578c0de
ee01c32
38a1175
4cea63e
c21245b
0d7f385
082eebf
3201494
b79a3e0
a35f8a3
556234b
4158509
de3d99f
df1083f
5a4a594
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } |
| 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
|
||
Uh oh!
There was an error while loading. Please reload this page.