NextReceipt/internal/handlers/purchases.go
cclohmar f0c576c543 feat: transform NextExpense into NextReceipt — receipt saver for warranty & returns
- Replace events+expenses with flat purchases table
- Add warranty_months, return_days, product_name fields
- Remove currency conversion, CSV/PDF reporting, event filing
- Simplify auth (no onboarding/department/profile)
- Update AI extraction prompts for product/warranty info
- Update all branding: templates, install.sh, Makefile, service file
2026-06-21 18:57:29 +00:00

669 lines
23 KiB
Go

// Package handlers provides HTTP request handlers for NextReceipt.
//
// This file implements purchase upload, AI extraction, and save handlers
// using HTMX partial responses.
package handlers
import (
"bytes"
"database/sql"
"fmt"
"html/template"
"image"
"image/jpeg"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/image/draw"
"github.com/cclohmar/NextReceipt/internal/ai"
"github.com/cclohmar/NextReceipt/internal/database"
"github.com/cclohmar/NextReceipt/internal/utils"
)
// ---------------------------------------------------------------------------
// PurchaseHandler
// ---------------------------------------------------------------------------
// PurchaseHandler groups HTTP handlers related to purchase/receipt management.
type PurchaseHandler struct {
DB *sql.DB
}
// NewPurchaseHandler creates a new PurchaseHandler with the given database handle.
func NewPurchaseHandler(db *sql.DB) *PurchaseHandler {
return &PurchaseHandler{DB: db}
}
// ---------------------------------------------------------------------------
// POST /purchases/upload — UploadReceipt
// ---------------------------------------------------------------------------
// UploadReceipt handles receipt image upload, AI extraction, and returns
// an HTMX fragment with a pre-filled receipt edit form.
func (h *PurchaseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
// 1. Parse multipart form with 10 MB max memory.
if err := r.ParseMultipartForm(10 << 20); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: parse form: %v",
time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Failed to parse upload form.")
return
}
defer r.MultipartForm.RemoveAll()
// 2. Get the file from the "receipt" form field.
file, header, err := r.FormFile("receipt")
if err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: missing receipt field: %v",
time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Missing receipt file.")
return
}
defer file.Close()
// 3. Validate file size (max 10 MB).
if header.Size > 10<<20 {
log.Printf("ERROR [%s] handlers: UploadReceipt: file too large: %d bytes",
time.Now().Format(time.RFC3339), header.Size)
renderUploadError(w, "File too large. Maximum size is 10 MB.")
return
}
// 4. Read the full file data.
fileData, err := io.ReadAll(file)
if err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: read file: %v",
time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Failed to read uploaded file.")
return
}
// 5. Validate content type by inspecting magic bytes.
ext := detectImageExtension(fileData)
if ext == "" {
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported file type")
renderUploadError(w, "Unsupported file format. Please upload a receipt image (JPEG, PNG, HEIC) or PDF.")
return
}
// Resize the image (max 2048px, JPEG 85%).
resized, resizeErr := resizeImage(fileData)
if resizeErr == nil && len(resized) > 0 {
fileData = resized
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)
if err := os.MkdirAll("storage", 0755); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: mkdir storage: %v",
time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Server error. Please try again.")
return
}
// 7. Save the image file to disk.
if err := os.WriteFile(storagePath, fileData, 0644); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: write file: %v",
time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Failed to save receipt image. Please try again.")
return
}
// 8. Call the AI API for receipt data extraction.
receipt, aiErr := ai.ExtractReceipt(filepath.Join("storage", filename))
// Strip the storage/ prefix so the template can build a proper URL: /storage/{file}
storagePath = filename
// 9. Render the receipt_form.html fragment.
tmpl := getTemplate("receipt_form.html")
data := map[string]interface{}{
"ImagePath": storagePath,
"AIError": "",
"ProductName": "",
"Store": "",
"Category": "",
"Amount": "",
"Currency": "",
"Date": "",
"WarrantyMonths": "0",
"ReturnDays": "0",
"Notes": "",
}
if aiErr != nil {
data["AIError"] = "Could not read receipt automatically. Please fill in the fields below."
log.Printf("ERROR [%s] handlers: UploadReceipt: AI extraction failed: %v",
time.Now().Format(time.RFC3339), aiErr)
} else if receipt != nil {
data["ProductName"] = receipt.ProductName
data["Store"] = receipt.Merchant
data["Category"] = receipt.Category
data["Amount"] = strconv.FormatFloat(receipt.Amount, 'f', 2, 64)
data["Currency"] = receipt.Currency
data["Date"] = receipt.Date
if receipt.WarrantyMonths > 0 {
data["WarrantyMonths"] = strconv.Itoa(receipt.WarrantyMonths)
}
if receipt.ReturnDays > 0 {
data["ReturnDays"] = strconv.Itoa(receipt.ReturnDays)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: template execute: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// POST /purchases — SavePurchase
// ---------------------------------------------------------------------------
// SavePurchase handles the receipt form submission, saves the purchase to the
// database, and returns an HTMX multi-target response.
func (h *PurchaseHandler) SavePurchase(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
http.Error(w, "Session expired. Please log in again.", http.StatusUnauthorized)
return
}
// 1. Parse form fields.
if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: SavePurchase: parse form: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
return
}
productName := strings.TrimSpace(r.FormValue("product_name"))
store := strings.TrimSpace(r.FormValue("store"))
category := strings.TrimSpace(r.FormValue("category"))
amountStr := strings.TrimSpace(r.FormValue("amount"))
currency := strings.TrimSpace(r.FormValue("currency"))
date := strings.TrimSpace(r.FormValue("date"))
notes := strings.TrimSpace(r.FormValue("notes"))
imagePath := strings.TrimSpace(r.FormValue("image_path"))
warrantyStr := strings.TrimSpace(r.FormValue("warranty_months"))
returnStr := strings.TrimSpace(r.FormValue("return_days"))
// 2. Validate required fields.
var missing []string
if productName == "" {
missing = append(missing, "product name")
}
if store == "" {
missing = append(missing, "store")
}
if category == "" {
missing = append(missing, "category")
}
if amountStr == "" {
missing = append(missing, "amount")
}
if currency == "" {
missing = append(missing, "currency")
}
if date == "" {
missing = append(missing, "purchase date")
}
if len(missing) > 0 {
http.Error(w, "Missing required fields: "+strings.Join(missing, ", "),
http.StatusBadRequest)
return
}
// 3. Parse numeric fields.
amount, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
http.Error(w, "Invalid amount value", http.StatusBadRequest)
return
}
warrantyMonths := 0
if warrantyStr != "" {
warrantyMonths, _ = strconv.Atoi(warrantyStr)
}
returnDays := 0
if returnStr != "" {
returnDays, _ = strconv.Atoi(returnStr)
}
// 4. Build and save the purchase record.
purchase := database.Purchase{
ID: utils.NewUUID(),
UserID: userID,
ProductName: productName,
Store: store,
Category: category,
Amount: amount,
Currency: currency,
PurchaseDate: date,
WarrantyMonths: warrantyMonths,
ReturnDays: returnDays,
Notes: notes,
ImagePath: imagePath,
}
if err := database.CreatePurchase(h.DB, purchase); err != nil {
log.Printf("ERROR [%s] handlers: SavePurchase: create purchase: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to save purchase", http.StatusInternalServerError)
return
}
// Mark user as onboarded on first purchase.
database.MarkUserOnboarded(h.DB, userID)
// 5. Fetch the updated purchase list.
purchases, err := database.GetPurchasesByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: SavePurchase: fetch purchases: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to retrieve purchases", http.StatusInternalServerError)
return
}
// Normalize ImagePath.
for i := range purchases {
purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath)
}
// 6. Render the purchase list fragment.
listBuf := renderPurchaseList(purchases)
// 7. Return HTMX multi-target response.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Refresh", "false")
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"><div style="background: #064e3b; border: 1px solid #065f46; color: #6ee7b7; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">Purchase saved!</div></div>`)
fmt.Fprintf(w, `<div id="purchase-list" hx-swap-oob="true">%s</div>`, listBuf)
}
// ---------------------------------------------------------------------------
// GET /purchases/{id}/edit — EditPurchase
// ---------------------------------------------------------------------------
// EditPurchase returns the receipt form pre-filled with an existing purchase's data.
func (h *PurchaseHandler) EditPurchase(w http.ResponseWriter, r *http.Request) {
purchaseID := chi.URLParam(r, "id")
if purchaseID == "" {
http.Error(w, "Missing purchase ID", http.StatusBadRequest)
return
}
purchase, err := database.GetPurchaseByID(h.DB, purchaseID)
if err != nil {
log.Printf("ERROR [%s] handlers: EditPurchase: GetPurchaseByID(%s): %v",
time.Now().Format(time.RFC3339), purchaseID, err)
http.Error(w, "Failed to retrieve purchase", http.StatusInternalServerError)
return
}
if purchase == nil {
http.Error(w, "Purchase not found", http.StatusNotFound)
return
}
// Verify ownership.
if purchase.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
tmpl := getTemplate("receipt_form.html")
data := map[string]interface{}{
"ImagePath": normalizeImagePath(purchase.ImagePath),
"AIError": "",
"ProductName": purchase.ProductName,
"Store": purchase.Store,
"Category": purchase.Category,
"Amount": strconv.FormatFloat(purchase.Amount, 'f', 2, 64),
"Currency": purchase.Currency,
"Date": purchase.PurchaseDate,
"WarrantyMonths": strconv.Itoa(purchase.WarrantyMonths),
"ReturnDays": strconv.Itoa(purchase.ReturnDays),
"Notes": purchase.Notes,
"EditID": purchase.ID,
"ID": purchase.ID,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: EditPurchase: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// PUT /purchases/{id} — UpdatePurchase
// ---------------------------------------------------------------------------
// UpdatePurchase updates an existing purchase record with form data.
func (h *PurchaseHandler) UpdatePurchase(w http.ResponseWriter, r *http.Request) {
purchaseID := chi.URLParam(r, "id")
if purchaseID == "" {
http.Error(w, "Missing purchase ID", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: UpdatePurchase: parse form: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
return
}
existing, err := database.GetPurchaseByID(h.DB, purchaseID)
if err != nil || existing == nil {
log.Printf("ERROR [%s] handlers: UpdatePurchase: get existing: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Purchase not found", http.StatusNotFound)
return
}
if existing.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
warrantyMonths, _ := strconv.Atoi(r.FormValue("warranty_months"))
returnDays, _ := strconv.Atoi(r.FormValue("return_days"))
purchase := database.Purchase{
ID: purchaseID,
ProductName: strings.TrimSpace(r.FormValue("product_name")),
Store: strings.TrimSpace(r.FormValue("store")),
Category: strings.TrimSpace(r.FormValue("category")),
Amount: amount,
Currency: strings.TrimSpace(r.FormValue("currency")),
PurchaseDate: strings.TrimSpace(r.FormValue("date")),
WarrantyMonths: warrantyMonths,
ReturnDays: returnDays,
Notes: strings.TrimSpace(r.FormValue("notes")),
}
if err := database.UpdatePurchase(h.DB, purchase); err != nil {
log.Printf("ERROR [%s] handlers: UpdatePurchase: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to update purchase", http.StatusInternalServerError)
return
}
// Return updated purchase list.
userID := getUserID(r)
purchases, err := database.GetPurchasesByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: UpdatePurchase: fetch purchases: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to fetch purchases", http.StatusInternalServerError)
return
}
for i := range purchases {
purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath)
}
listBuf := renderPurchaseList(purchases)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"><div style="background: #064e3b; border: 1px solid #065f46; color: #6ee7b7; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">Purchase updated!</div></div>`)
fmt.Fprintf(w, `<div id="purchase-list" hx-swap-oob="true">%s</div>`, listBuf)
}
// ---------------------------------------------------------------------------
// DELETE /purchases/{id} — DeletePurchase
// ---------------------------------------------------------------------------
// DeletePurchase removes an individual purchase after verifying ownership.
func (h *PurchaseHandler) DeletePurchase(w http.ResponseWriter, r *http.Request) {
purchaseID := chi.URLParam(r, "id")
if purchaseID == "" {
http.Error(w, "Missing purchase ID", http.StatusBadRequest)
return
}
existing, err := database.GetPurchaseByID(h.DB, purchaseID)
if err != nil || existing == nil {
log.Printf("ERROR [%s] handlers: DeletePurchase: get existing(%s): %v",
time.Now().Format(time.RFC3339), purchaseID, err)
http.Error(w, "Purchase not found", http.StatusNotFound)
return
}
if existing.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
userID := existing.UserID
if err := database.DeletePurchase(h.DB, purchaseID); err != nil {
log.Printf("ERROR [%s] handlers: DeletePurchase: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to delete purchase", http.StatusInternalServerError)
return
}
// Fetch the updated purchase list.
purchases, err := database.GetPurchasesByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: DeletePurchase: fetch purchases: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to fetch purchases", http.StatusInternalServerError)
return
}
for i := range purchases {
purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath)
}
listBuf := renderPurchaseList(purchases)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"></div>`)
fmt.Fprintf(w, `<div id="purchase-list" hx-swap-oob="true">%s</div>`, listBuf)
}
// ---------------------------------------------------------------------------
// Purchase list rendering helper
// ---------------------------------------------------------------------------
// renderPurchaseList renders the purchase list HTML fragment.
func renderPurchaseList(purchases []database.Purchase) string {
var buf strings.Builder
buf.WriteString(fmt.Sprintf(`<h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;">Purchases (%d)</h3>`, len(purchases)))
if len(purchases) == 0 {
buf.WriteString(`<div style="text-align: center; padding: 3rem; color: var(--color-text-muted); font-size: 0.875rem;"><p>No purchases yet. Upload a receipt to get started.</p></div>`)
return buf.String()
}
buf.WriteString(`<div style="display: flex; flex-direction: column; gap: 0.5rem;">`)
for _, p := range purchases {
buf.WriteString(renderPurchaseItem(p))
}
buf.WriteString(`</div>`)
return buf.String()
}
// renderPurchaseItem renders a single purchase item HTML.
func renderPurchaseItem(p database.Purchase) string {
warrantyInfo := ""
if p.WarrantyMonths > 0 {
warrantyInfo = fmt.Sprintf(`<div style="font-size: 0.7rem; color: #6ee7b7;">Warranty: %d months</div>`, p.WarrantyMonths)
}
returnInfo := ""
if p.ReturnDays > 0 {
returnInfo = fmt.Sprintf(`<div style="font-size: 0.7rem; color: #fcd34d;">Return: %d days</div>`, p.ReturnDays)
}
notesInfo := ""
if p.Notes != "" {
notesInfo = fmt.Sprintf(`<div style="font-size: 0.7rem; color: var(--color-text-muted);">%s</div>`, template.HTMLEscapeString(p.Notes))
}
imgBtn := ""
imgDiv := ""
if p.ImagePath != "" {
imgBtn = fmt.Sprintf(`<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
onclick="document.getElementById('img-%s').classList.toggle('hidden')"
title="View receipt image">🖼️</button>`, p.ID)
imgDiv = fmt.Sprintf(`<div id="img-%s" class="hidden" style="position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 999; align-items: center; justify-content: center; cursor: pointer; padding: 1rem;"
onclick="this.classList.add('hidden')">
<span style="position: absolute; top: 1rem; right: 1rem; font-size: 2rem; color: #fff; line-height: 1; cursor: pointer; z-index: 1000;">&times;</span>
<img src="/storage/%s" alt="Receipt" style="max-width: 100%%; max-height: 100%%; object-fit: contain; border-radius: 0.5rem;" onclick="event.stopPropagation()">
</div>`, p.ID, template.HTMLEscapeString(p.ImagePath))
}
return fmt.Sprintf(`<div style="display: flex; align-items: center; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 0.75rem;">
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; color: var(--color-text);">%s</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">%s · %s · %s %s</div>
%s%s%s
</div>
<div style="text-align: right; margin-right: 0.75rem;">
<div style="font-weight: 600; color: var(--color-text);">%s %.2f</div>
</div>
<div style="display: flex; gap: 0.25rem;">
%s
<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
hx-get="/purchases/%s/edit"
hx-target="#receipt-form"
hx-swap="innerHTML"
title="Edit purchase">✏️</button>
<button class="btn btn-sm" style="background: none; border: 1px solid #7f1d1d; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: #fca5a5;"
onclick="if(confirm('Delete this purchase?')) htmx.trigger('#del-%s','click')"
title="Delete purchase">🗑️</button>
<div hx-delete="/purchases/%s" hx-target="#purchase-list" hx-swap="outerHTML" id="del-%s" style="display:none"></div>
</div>
</div>%s`,
template.HTMLEscapeString(p.ProductName),
template.HTMLEscapeString(p.Store), template.HTMLEscapeString(p.Category), template.HTMLEscapeString(p.PurchaseDate), template.HTMLEscapeString(p.Currency),
warrantyInfo, returnInfo, notesInfo,
template.HTMLEscapeString(p.Currency), p.Amount,
imgBtn,
template.HTMLEscapeString(p.ID), template.HTMLEscapeString(p.ID), template.HTMLEscapeString(p.ID),
imgDiv)
}
// ---------------------------------------------------------------------------
// Shared helpers (from expenses.go)
// ---------------------------------------------------------------------------
// renderUploadError writes an HTMX-compatible error fragment.
func renderUploadError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `<div id="receipt-form"><div style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div></div>`,
template.HTMLEscapeString(message))
}
// detectImageExtension examines the magic bytes of the provided data to
// determine its image format. Supports JPEG, PNG, WebP, GIF, BMP, TIFF,
// HEIC/HEIF, and PDF.
func detectImageExtension(data []byte) string {
if len(data) < 4 {
return ""
}
// JPEG: FF D8 FF
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "jpg"
}
// PNG: 89 50 4E 47
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E &&
data[3] == 0x47 && data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A {
return "png"
}
// WebP
if len(data) >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
return "webp"
}
// GIF
if len(data) >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x38 && (data[4] == 0x39 || data[4] == 0x37) && data[5] == 0x61 {
return "gif"
}
// BMP
if data[0] == 0x42 && data[1] == 0x4D {
return "bmp"
}
// TIFF
if (data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00) ||
(data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) {
return "tiff"
}
// PDF
if len(data) >= 4 && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 {
return "pdf"
}
// HEIC/HEIF/AVIF
if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 {
brand := string(data[8:12])
switch brand {
case "heic", "heix", "hevc", "hevx", "mif1", "msf1":
return "heic"
case "avif":
return "avif"
}
}
return ""
}
// 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.
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
}
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
}