package controllers import ( "crypto/rand" "encoding/hex" "fmt" "strings" "time" "doormile/constants" "doormile/internal/storage" "doormile/utils" "github.com/gofiber/fiber/v2" ) // uploadPurpose maps an app-supplied purpose to the bucket folder the legacy // rider app already used, so Doormile proof images land beside jupiter's. // delivered/ and picked/ are jupiter's proof folders; support/ its ticket // folder. A signature rides in the delivered/ folder with its own prefix. var uploadFolder = map[string]string{ "delivery_proof": "delivered", "pickup_proof": "picked", "receiver_signature": "delivered", "support": "support", } // allowedUploadContentType restricts uploads to the image types the rider app // captures. Empty defaults to JPEG (what the app sends today). var allowedUploadContentType = map[string]string{ "": "jpg", "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", } // MilerSignUpload hands the rider a short-lived presigned PUT URL for a proof // photo or signature, plus the public CDN URL the image will have once // uploaded. The app PUTs the file to uploadurl (echoing the returned headers), // then sends url back as photourl / receiversignatureurl on deliver or skip. // // This replaces the legacy flow where the Flutter app carried the Spaces // access/secret key and wrote to the bucket directly — the key now stays on the // server and the app only ever holds a URL that expires. func MilerSignUpload(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var req struct { Purpose string `json:"purpose"` ContentType string `json:"contenttype"` Consignmentid int `json:"consignmentid"` Bookingid int `json:"bookingid"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } folder, ok := uploadFolder[strings.ToLower(strings.TrimSpace(req.Purpose))] if !ok { return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "purpose must be one of delivery_proof, pickup_proof, receiver_signature, support") } ext, ok := allowedUploadContentType[strings.ToLower(strings.TrimSpace(req.ContentType))] if !ok { return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "contentType must be image/jpeg or image/png") } contentType := strings.ToLower(strings.TrimSpace(req.ContentType)) if contentType == "" { contentType = "image/jpeg" } if !storage.Configured() { return utils.Fail(c, fiber.StatusServiceUnavailable, "UPLOAD_NOT_CONFIGURED", "file uploads are not configured on this server") } // Key: {folder}/{tag}-{id}-{YYYYMMDD}-{HHMMSS}-{rand}.{ext}, mirroring // jupiter's "{folder}-{id}-{date}-{time}.jpg" and keyed on the consignment // (falling back to booking, then the rider) so a proof is traceable to its // stop. The random suffix keeps two photos of the same stop from colliding. id := req.Consignmentid if id == 0 { id = req.Bookingid } if id == 0 { id = milerUserID } tag := folder if req.Purpose == "receiver_signature" { tag = "signature" } now := time.Now().UTC() key := fmt.Sprintf("%s/%s-%d-%s-%s-%s.%s", folder, tag, id, now.Format("20060102"), now.Format("150405"), randToken(4), ext) presigned, err := storage.PresignPut(key, contentType, 10*time.Minute) if err != nil { utils.Warn("upload: presign failed", "miler_userid", milerUserID, "key", key, "error", err) return utils.Internal(c, "failed to prepare upload") } return utils.OK(c, presigned) } // randToken returns n random bytes as hex (2n chars). func randToken(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { // Non-fatal: the timestamp already makes the key near-unique; fall back // to a fixed marker rather than failing the upload over entropy. return "0000" } return hex.EncodeToString(b) }