// 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 }