diff --git a/go.mod b/go.mod index 507742b..1fee12c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/jung-kurt/gofpdf v1.16.2 + golang.org/x/image v0.18.0 modernc.org/sqlite v1.37.1 ) diff --git a/go.sum b/go.sum index 9c2ba80..4464ff0 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= diff --git a/internal/handlers/expenses.go b/internal/handlers/expenses.go index 2b9ded8..cef7d36 100644 --- a/internal/handlers/expenses.go +++ b/internal/handlers/expenses.go @@ -5,9 +5,12 @@ package handlers import ( + "bytes" "database/sql" "fmt" "html/template" + "image" + "image/jpeg" "io" "log" "net/http" @@ -19,6 +22,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "golang.org/x/image/draw" "github.com/cclohmar/ReceiptNext/internal/ai" "github.com/cclohmar/ReceiptNext/internal/database" @@ -101,6 +105,17 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) { return } + // Resize the image (max 2048px, JPEG 85%) to keep attachment sizes manageable + // and prevent SMTP size-limit rejections when filing events with many receipts. + resized, resizeErr := resizeImage(fileData) + if resizeErr == nil && len(resized) > 0 { + fileData = resized + // If the original was not JPEG, update extension since output is always JPEG. + if ext != "jpg" && ext != "jpeg" { + ext = "jpg" + } + } + // 6. Generate a UUID-based filename and ensure the storage directory exists. filename := utils.NewUUID() + "." + ext storagePath := filepath.Join("storage", filename) @@ -659,3 +674,46 @@ func detectImageExtension(data []byte) string { return "" } + +// --------------------------------------------------------------------------- +// Image processing helpers +// --------------------------------------------------------------------------- + +// resizeImage resizes image data to a maximum of 2048 pixels on the longest +// side while maintaining aspect ratio. Output is always JPEG at 85% quality. +// Returns the original data unchanged if the image is already smaller, or if +// decoding/resizing fails (e.g. unsupported format like HEIC). +func resizeImage(data []byte) ([]byte, error) { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return data, err + } + + bounds := img.Bounds() + w, h := bounds.Dx(), bounds.Dy() + + const maxDim = 2048 + if w <= maxDim && h <= maxDim { + return data, nil // already small enough + } + + // Maintain aspect ratio. + var newW, newH int + if w > h { + newW = maxDim + newH = h * maxDim / w + } else { + newH = maxDim + newW = w * maxDim / h + } + + dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil) + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil { + return data, err + } + + return buf.Bytes(), nil +}