Rider proof-of-delivery / signature photos need a way to reach storage.
The legacy (jupiter) rider app shipped the DigitalOcean Spaces access/secret
key inside the Flutter build and PUT to the bucket directly. This moves the
key server-side and hands the app a short-lived presigned PUT URL instead.
- internal/storage/spaces.go: self-contained AWS SigV4 query presigner for
Spaces (S3 API) — no aws-sdk-go-v2 dependency for a single presign op.
Verified live end-to-end (presign -> PUT 200 -> CDN GET matches).
- controllers/uploadController.go: POST /miler/uploads/sign returns
{ uploadurl, url, method, headers, key, expiresin }. Same bucket/folders/
CDN (images.nearle.app) as jupiter so images share one store.
- Reads DO_SPACES_* from .env via godotenv; returns 503 UPLOAD_NOT_CONFIGURED
when unset rather than handing out URLs that 403.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
117 lines
3.8 KiB
Go
117 lines
3.8 KiB
Go
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)
|
|
}
|