package api import ( "errors" "net/http" "time" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/imagenorm" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // Seed-packet capture (#81). Two steps, deliberately separate: // POST /seed-lots/scan multipart image → a proposal (reads only) // POST /seed-lots/from-packet confirmed proposal → a plant + lot // The scan never writes; creation happens only from an explicit confirm, so a // misread can't add anything to the catalog on its own. // scanUploadLimit bounds the multipart body. imagenorm caps the decoded image at // 25 MiB; this is a little over that for the multipart envelope. A phone photo is // a few MB, so this is generous. const scanUploadLimit = 30 << 20 // scanReadTimeout is how long we allow the image upload to take. The server's // default ReadTimeout (15s) is fine for JSON but tight for a multi-megabyte photo // on a slow phone connection, so this endpoint extends it — the same // ResponseController mechanism the SSE path uses for writes (#78). const scanReadTimeout = 60 * time.Second // scanWriteTimeout extends the write deadline for the same reason. The server's // absolute WriteTimeout (30s) is measured from the start of the request, but this // handler's response can't be written until AFTER a slow upload AND a live vision // call — together easily past 30s. Without this, a successful extraction's // response is silently dropped: the exact failure mode #78 fixed for SSE. const scanWriteTimeout = 120 * time.Second // scanSeedPacket reads an uploaded packet photo and returns a proposal. func (h *handlers) scanSeedPacket(c *gin.Context) { // Extend both deadlines for the (potentially large, potentially slow) upload // and the live vision call that follows. Best-effort: if the writer doesn't // support it, the server defaults apply. rc := http.NewResponseController(c.Writer) _ = rc.SetReadDeadline(time.Now().Add(scanReadTimeout)) _ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout)) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit) file, err := c.FormFile("image") if err != nil { // A body over scanUploadLimit trips MaxBytesReader — that's 413, not a // malformed request. Everything else here is a genuinely missing/garbled // multipart field. var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo") return } writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "attach an image as the \"image\" field") return } f, err := file.Open() if err != nil { // Opening the parsed upload failed on our side, not the client's. writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not read the uploaded image") return } defer f.Close() // Normalize to JPEG (decodes HEIC/webp/png/jpeg, downscales, re-encodes) so // everything downstream — including the vision model — only sees a format it // can read. This is where an iPhone HEIC becomes usable. jpeg, _, err := imagenorm.Normalize(f, imagenorm.Options{}) if err != nil { switch { case errors.Is(err, imagenorm.ErrTooLarge): writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo") case errors.Is(err, imagenorm.ErrUnsupported): writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "that doesn't look like an image we can read (JPEG, PNG, HEIC or WebP)") default: // A read or re-encode fault is ours, not bad input. writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not process the uploaded image") } return } prop, err := h.svc.ExtractSeedPacket(c.Request.Context(), mustActor(c).ID, jpeg) if err != nil { // A missing vision model surfaces as ErrInvalidInput from the service; give // it a clearer message than the generic 400, since the UI shouldn't have // offered the button at all in that case. if errors.Is(err, domain.ErrInvalidInput) { writeAPIError(c, http.StatusServiceUnavailable, "VISION_DISABLED", "packet scanning isn't set up on this instance") return } writeServiceError(c, err) return } c.JSON(http.StatusOK, prop) } // fromPacketRequest confirms a proposal: exactly one of plantId (attach to an // existing plant) or newPlant (create a variety), plus the lot to record. The lot // is seedLotFields — the create body's lot half WITHOUT plantId, since the plant // comes from the plantId/newPlant choice, not the lot body. type fromPacketRequest struct { PlantID *int64 `json:"plantId"` NewPlant *plantCreateRequest `json:"newPlant"` Lot seedLotFields `json:"lot"` } // createFromPacket turns a confirmed proposal into a plant + lot. func (h *handlers) createFromPacket(c *gin.Context) { var req fromPacketRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a lot and exactly one of plantId or newPlant are required") return } confirm := service.PacketConfirm{ PlantID: req.PlantID, Lot: req.Lot.toInput(), // no plantId in the lot body; the service attributes it } if req.NewPlant != nil { in := req.NewPlant.toInput() confirm.NewPlant = &in } res, err := h.svc.CreateFromPacket(c.Request.Context(), mustActor(c).ID, confirm) if err != nil { writeServiceError(c, err) return } c.JSON(http.StatusCreated, res) }