mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 00:01:14 +00:00
235 lines
6.8 KiB
Go
235 lines
6.8 KiB
Go
// Campaign email attachment handlers — upload, list, delete. Binary lives in
|
|
// object storage (private; surfaced to the browser via short-lived presigned
|
|
// URLs and fetched by the worker at send time). Overall storage is capped per
|
|
// organization by the plan-based quota (feature gate).
|
|
|
|
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
const (
|
|
// Per-file cap. Kept under provider message ceilings (Gmail/Outlook ~25 MB
|
|
// total, and base64 inflates ~37%), so a single attachment always fits.
|
|
attachmentMaxBytes int64 = 15 * 1024 * 1024
|
|
attachmentURLTTL = 15 * time.Minute
|
|
)
|
|
|
|
// Executable / script types that must never ride an outbound email. Everything
|
|
// else (images, PDF, office docs, CSV, archives, …) is allowed.
|
|
var blockedAttachmentExt = map[string]bool{
|
|
".exe": true, ".bat": true, ".cmd": true, ".com": true, ".msi": true,
|
|
".scr": true, ".js": true, ".jse": true, ".vbs": true, ".vbe": true,
|
|
".ps1": true, ".sh": true, ".jar": true, ".app": true, ".dll": true,
|
|
".cpl": true, ".hta": true, ".wsf": true, ".pif": true,
|
|
}
|
|
|
|
func sanitizeFilename(name string) string {
|
|
name = path.Base(strings.ReplaceAll(name, "\\", "/"))
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || name == "." || name == ".." {
|
|
return "file"
|
|
}
|
|
if len(name) > 200 {
|
|
name = name[len(name)-200:]
|
|
}
|
|
return name
|
|
}
|
|
|
|
func mb(b int64) int64 { return b / (1024 * 1024) }
|
|
|
|
// UploadCampaignAttachment — POST /campaigns/:id/attachments (multipart "file")
|
|
func (h *Handler) UploadCampaignAttachment(c *gin.Context) {
|
|
campaignID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.ErrUuid)
|
|
return
|
|
}
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
userID, err := uuid.Parse(middleware.GetUserID(c))
|
|
if err != nil {
|
|
errx.JSON(c, errx.ErrAuth)
|
|
return
|
|
}
|
|
if h.Storage == nil {
|
|
errx.JSON(c, errx.New(errx.ServiceUnavailable, "object storage not configured"))
|
|
return
|
|
}
|
|
|
|
// Optional sequence_id form field scopes the attachment to one step.
|
|
var seqID *uuid.UUID
|
|
if s := strings.TrimSpace(c.PostForm("step_id")); s != "" {
|
|
if id, perr := uuid.Parse(s); perr == nil {
|
|
seqID = &id
|
|
}
|
|
}
|
|
|
|
// Cap the body before parsing so a huge upload can't pin a worker.
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, attachmentMaxBytes+(1<<20))
|
|
fh, err := c.FormFile("file")
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "file is required"))
|
|
return
|
|
}
|
|
if fh.Size <= 0 || fh.Size > attachmentMaxBytes {
|
|
errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("file must be between 1 byte and %d MB", mb(attachmentMaxBytes))))
|
|
return
|
|
}
|
|
filename := sanitizeFilename(fh.Filename)
|
|
if blockedAttachmentExt[strings.ToLower(path.Ext(filename))] {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "that file type can't be attached to email"))
|
|
return
|
|
}
|
|
|
|
// Plan-based overall storage quota (org-wide).
|
|
limit, xerr := h.FeatureGateService.GetStorageLimitBytes(c.Request.Context(), *orgID)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
used, err := h.AttachmentRepo.SumStorageUsedByOrg(c.Request.Context(), *orgID)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
if used+fh.Size > limit {
|
|
errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf(
|
|
"storage limit reached (%d MB of %d MB used) — remove attachments or upgrade your plan", mb(used), mb(limit))))
|
|
return
|
|
}
|
|
|
|
src, err := fh.Open()
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
defer src.Close()
|
|
buf := &bytes.Buffer{}
|
|
if _, err := io.Copy(buf, src); err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
body := buf.Bytes()
|
|
mimeType := fh.Header.Get("Content-Type")
|
|
if mimeType == "" {
|
|
mimeType = http.DetectContentType(body)
|
|
}
|
|
|
|
key := fmt.Sprintf("attachments/%s/%s-%s", campaignID.String(), uuid.NewString(), filename)
|
|
if err := h.Storage.Put(c.Request.Context(), key, bytes.NewReader(body), mimeType); err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
|
|
att := &models.CampaignAttachment{
|
|
CampaignID: campaignID,
|
|
SequenceID: seqID,
|
|
UserID: userID,
|
|
Filename: filename,
|
|
Size: fh.Size,
|
|
MimeType: mimeType,
|
|
S3Key: key,
|
|
}
|
|
if err := h.AttachmentRepo.Create(c.Request.Context(), att); err != nil {
|
|
_ = h.Storage.Delete(c.Request.Context(), key) // best-effort cleanup
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
|
|
h.auditOrg(c, models.AuditActionCreate, models.AuditEntityCampaign, &att.ID, nil, map[string]string{
|
|
"scope": "attachment", "campaign_id": campaignID.String(), "filename": filename,
|
|
})
|
|
|
|
c.JSON(http.StatusCreated, h.attachmentResponse(c, att))
|
|
}
|
|
|
|
// ListCampaignAttachments — GET /campaigns/:id/attachments
|
|
func (h *Handler) ListCampaignAttachments(c *gin.Context) {
|
|
campaignID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.ErrUuid)
|
|
return
|
|
}
|
|
atts, err := h.AttachmentRepo.ListByCampaign(c.Request.Context(), campaignID)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
out := make([]gin.H, 0, len(atts))
|
|
for i := range atts {
|
|
out = append(out, h.attachmentResponse(c, &atts[i]))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
|
}
|
|
|
|
// DeleteCampaignAttachment — DELETE /campaigns/:id/attachments/:attachmentId
|
|
func (h *Handler) DeleteCampaignAttachment(c *gin.Context) {
|
|
campaignID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.ErrUuid)
|
|
return
|
|
}
|
|
attID, err := uuid.Parse(c.Param("attachmentId"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.ErrUuid)
|
|
return
|
|
}
|
|
att, err := h.AttachmentRepo.GetByID(c.Request.Context(), attID)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
if att == nil || att.CampaignID != campaignID {
|
|
errx.JSON(c, errx.ErrNotFound)
|
|
return
|
|
}
|
|
if err := h.AttachmentRepo.Delete(c.Request.Context(), attID); err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
if h.Storage != nil {
|
|
_ = h.Storage.Delete(c.Request.Context(), att.S3Key)
|
|
}
|
|
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityCampaign, &attID, nil, map[string]string{
|
|
"scope": "attachment", "campaign_id": campaignID.String(),
|
|
})
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) attachmentResponse(c *gin.Context, att *models.CampaignAttachment) gin.H {
|
|
url := ""
|
|
if h.Storage != nil {
|
|
if u, err := h.Storage.PresignedGetURL(c.Request.Context(), att.S3Key, attachmentURLTTL); err == nil {
|
|
url = u
|
|
}
|
|
}
|
|
return gin.H{
|
|
"id": att.ID,
|
|
"campaign_id": att.CampaignID,
|
|
"step_id": att.SequenceID,
|
|
"filename": att.Filename,
|
|
"size": att.Size,
|
|
"mime_type": att.MimeType,
|
|
"url": url,
|
|
"created_at": att.CreatedAt,
|
|
}
|
|
}
|