Files
warmbly/internal/api/handler/email_image.go
T

299 lines
9.6 KiB
Go

// Workspace image library for email bodies (issue #380) — upload, list, delete.
//
// Unlike an attachment, an image placed in the body is fetched by the
// recipient's mail client with no session of ours, so the bytes are written as
// a public object and the row keeps the URL the composer inserts. The same
// per-plan storage quota that bounds attachments bounds these, under the same
// lock, because both live in the same object store.
package handler
import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"path"
"strconv"
"strings"
"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/infrastructure/storage"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/utils/paging"
)
const (
// Per-image cap. An email body image has to load on a phone over a slow
// connection, so this is deliberately far below the attachment ceiling.
emailImageMaxBytes int64 = 5 * 1024 * 1024
// Dimension backstop against a raw camera dump; the composer downscales
// before uploading, so this only catches a bypass of that path.
emailImageMaxDimension = 4000
// Default page size, and the ceiling a caller may ask for.
emailImageListLimit = 40
emailImageListMax = 100
)
// Formats every mail client renders. SVG is excluded on purpose: it is a
// script-capable document served from our own public origin, and no mail client
// renders it anyway.
//
// Keyed by what http.DetectContentType returns, never by what the client
// declared, so a name is the only thing the upload gets to choose.
var allowedEmailImageMIME = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
}
// UploadEmailImage — POST /email-images (multipart "file")
func (h *Handler) UploadEmailImage(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
if h.Storage == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "object storage not configured"))
return
}
if h.EmailImageRepo == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "image library not available"))
return
}
// Cap the body before the first form field is read: that read parses the
// whole multipart payload.
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, emailImageMaxBytes+(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 > emailImageMaxBytes {
errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("the image must be between 1 byte and %d MB", mb(emailImageMaxBytes))))
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()
// The sniff decides, not the client-declared type: this object is served
// back from our own public origin.
mimeType := http.DetectContentType(body)
ext, ok := allowedEmailImageMIME[mimeType]
if !ok {
errx.JSON(c, errx.New(errx.BadRequest, "the image must be a PNG, JPG, GIF or WebP"))
return
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(body))
if err != nil {
// WebP has no decoder registered, so its dimensions stay unknown
// rather than refusing a format every mail client renders.
if mimeType != "image/webp" {
errx.JSON(c, errx.New(errx.BadRequest, "the image could not be parsed"))
return
}
cfg = image.Config{}
}
if cfg.Width > emailImageMaxDimension || cfg.Height > emailImageMaxDimension {
errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("the image must be %dpx or smaller on each side", emailImageMaxDimension)))
return
}
filename := emailImageFilename(fh.Filename, ext)
key := models.EmailImageObjectKey(*orgID, ext)
stored, xerr := putPublicObject(c.Request.Context(), h.Storage, key, body, mimeType)
if xerr != nil {
errx.JSON(c, xerr)
return
}
url := absolutePublicURL(c, stored)
img := &models.EmailImage{
OrganizationID: *orgID,
Filename: filename,
MimeType: mimeType,
Size: fh.Size,
Width: cfg.Width,
Height: cfg.Height,
StorageKey: key,
URL: url,
}
if uid, perr := uuid.Parse(middleware.GetUserID(c)); perr == nil {
img.UserID = &uid
}
// The row is the reservation: written only if the org's total still fits
// once this image is counted, with the limit re-read under the lock. A
// refused image is removed from storage again, on a context that outlives
// the request so a cancelled upload cannot strand the object.
limitFn := func(ctx context.Context) (int64, error) {
l, xerr := h.FeatureGateService.GetStorageLimitBytes(ctx, *orgID)
if xerr != nil {
return 0, xerr
}
return l, nil
}
created, used, limit, err := h.EmailImageRepo.CreateWithinQuota(c.Request.Context(), img, limitFn)
if err != nil {
h.deleteObjectDetached(c.Request.Context(), key)
errx.JSON(c, errx.InternalError())
return
}
if !created {
h.deleteObjectDetached(c.Request.Context(), key)
errx.JSON(c, errx.StorageLimitReached(used, limit, fh.Size))
return
}
h.auditOrg(c, models.AuditActionCreate, models.AuditEntityEmailImage, &img.ID, nil, map[string]string{
"filename": filename,
})
c.JSON(http.StatusCreated, img)
}
// ListEmailImages — GET /email-images
func (h *Handler) ListEmailImages(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
if h.EmailImageRepo == nil {
c.JSON(http.StatusOK, gin.H{"data": []models.EmailImage{}, "pagination": gin.H{"next_cursor": nil, "has_more": false}})
return
}
limit := emailImageListLimit
if raw := strings.TrimSpace(c.Query("limit")); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > emailImageListMax {
errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("limit must be between 1 and %d", emailImageListMax)))
return
}
limit = n
}
beforeAt, beforeID, xerr := paging.DecodeTimeCursor(c.Query("cursor"))
if xerr != nil {
errx.JSON(c, xerr)
return
}
// One extra row answers "is there a next page" without a second count.
imgs, err := h.EmailImageRepo.ListByOrg(c.Request.Context(), *orgID, limit+1, beforeAt, beforeID)
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
var nextCursor *string
if len(imgs) > limit {
last := imgs[limit-1]
nextCursor = paging.EncodeTime(last.CreatedAt, last.ID)
imgs = imgs[:limit]
}
c.JSON(http.StatusOK, gin.H{
"data": imgs,
"pagination": gin.H{
"next_cursor": nextCursor,
"has_more": nextCursor != nil,
},
})
}
// DeleteEmailImage — DELETE /email-images/:id. The bytes go with the row, so
// any email already sent with this image loses it; the dashboard says so first.
func (h *Handler) DeleteEmailImage(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
if h.EmailImageRepo == nil {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "image library not available"))
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.ErrUuid)
return
}
img, err := h.EmailImageRepo.GetByID(c.Request.Context(), id)
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
// The route id is a raw path parameter, so ownership is proved here or an
// image of another workspace could be deleted.
if img == nil || img.OrganizationID != *orgID {
errx.JSON(c, errx.ErrNotFound)
return
}
// The object goes first. Deleting the row first and then failing here would
// leave a public URL that still loads while nothing counts its bytes
// against the quota and nothing remembers the key. This way a failure
// changes nothing and the caller can retry; the delete is idempotent, so a
// retry after the object is already gone still removes the row.
if h.Storage != nil {
if err := h.Storage.Delete(c.Request.Context(), img.StorageKey); err != nil && !errors.Is(err, storage.ErrNotFound) {
errx.JSON(c, errx.New(errx.ServiceUnavailable, "the image could not be removed from storage; try again"))
return
}
}
if err := h.EmailImageRepo.Delete(c.Request.Context(), id); err != nil {
errx.JSON(c, errx.InternalError())
return
}
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityEmailImage, &id, nil, map[string]string{
"filename": img.Filename,
})
c.Status(http.StatusNoContent)
}
// absolutePublicURL makes a public object URL loadable from outside this
// deployment. The filesystem backend returns a path when BLOB_PUBLIC_BASE_URL
// is unset, and a recipient's mail client cannot resolve a path, so it is
// anchored to this API's own origin.
func absolutePublicURL(c *gin.Context, stored string) string {
if stored == "" || strings.HasPrefix(stored, "http://") || strings.HasPrefix(stored, "https://") {
return stored
}
return publicAPIBaseURL(c) + "/" + strings.TrimPrefix(stored, "/")
}
// emailImageFilename is the display name kept on the row: what the library
// lists and what the alt text defaults to. The extension is forced to match
// the sniffed type so the name never claims to be something the bytes are not.
// It never reaches an object key, which is generated independently.
func emailImageFilename(raw, ext string) string {
name := sanitizeFilename(raw)
name = strings.TrimSpace(strings.TrimSuffix(name, path.Ext(name)))
if name == "" {
name = "image"
}
return name + ext
}