diff --git a/.env b/.env index 2dc260f..e6fe4e9 100644 --- a/.env +++ b/.env @@ -19,3 +19,13 @@ REDIS_PASSWORD=Package@321# # AI Decision Engine AI_LAYER_BASE_URL=https://routemate.workolik.com + +# DigitalOcean Spaces — rider proof-of-delivery / signature uploads. +# Same bucket/region/CDN as the legacy (jupiter) rider app so images share one +# store. Consumed by internal/storage for presigned PUT URLs (/miler/uploads/sign). +DO_SPACES_REGION=sgp1 +DO_SPACES_ENDPOINT=sgp1.digitaloceanspaces.com +DO_SPACES_BUCKET=nearle +DO_SPACES_ACCESS_KEY=DO00NQER7N2FRYZAB2HR +DO_SPACES_SECRET_KEY=nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0 +DO_SPACES_CDN_BASE=https://images.nearle.app diff --git a/controllers/uploadController.go b/controllers/uploadController.go new file mode 100644 index 0000000..87135e8 --- /dev/null +++ b/controllers/uploadController.go @@ -0,0 +1,116 @@ +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) +} diff --git a/internal/storage/spaces.go b/internal/storage/spaces.go new file mode 100644 index 0000000..c2f5922 --- /dev/null +++ b/internal/storage/spaces.go @@ -0,0 +1,225 @@ +// Package storage issues presigned upload URLs for the object store that holds +// rider proof-of-delivery photos and support-ticket images. +// +// The store is the same DigitalOcean Spaces bucket the legacy (jupiter) rider +// app already writes to — same bucket, same region, same folders, same public +// CDN (images.nearle.app) — so nothing new is provisioned and existing images +// keep resolving. The only change is WHERE the credentials live: jupiter shipped +// the Spaces access/secret key inside the Flutter app and let the client PUT +// directly. This moves the key server-side and hands the app a short-lived, +// pre-signed PUT URL instead, so a decompiled app no longer leaks a key with +// write access to the whole bucket. +// +// It is a self-contained AWS SigV4 query presigner (Spaces speaks the S3 API) +// rather than a dependency on aws-sdk-go-v2: a single presign-PUT operation does +// not justify pulling the SDK's tree into a module that has no other AWS use. +package storage + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "strings" + "time" +) + +// PresignedUpload is everything the app needs to push one file and then record +// where it landed: PUT the bytes to UploadURL with Headers set, then send URL +// back to the deliver/skip endpoint as photourl / receiversignatureurl. +type PresignedUpload struct { + UploadURL string `json:"uploadurl"` + URL string `json:"url"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + Key string `json:"key"` + ExpiresIn int `json:"expiresin"` +} + +// spacesConfig is read from the environment at call time (not startup) so ops +// can set the keys without a code change, exactly as jupiter's uploader did. +type spacesConfig struct { + region string + endpoint string + bucket string + accessKey string + secretKey string + cdnBase string +} + +func loadSpacesConfig() spacesConfig { + return spacesConfig{ + region: getenv("DO_SPACES_REGION", "sgp1"), + endpoint: getenv("DO_SPACES_ENDPOINT", "sgp1.digitaloceanspaces.com"), + bucket: getenv("DO_SPACES_BUCKET", "nearle"), + accessKey: os.Getenv("DO_SPACES_ACCESS_KEY"), + secretKey: os.Getenv("DO_SPACES_SECRET_KEY"), + cdnBase: strings.TrimRight(getenv("DO_SPACES_CDN_BASE", "https://images.nearle.app"), "/"), + } +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +// Configured reports whether the credentials needed to sign an upload are +// present. When false the caller should return a clear "uploads not configured" +// error rather than handing out a URL that will 403. +func Configured() bool { + cfg := loadSpacesConfig() + return cfg.accessKey != "" && cfg.secretKey != "" && cfg.bucket != "" +} + +// PresignPut returns a presigned S3 PUT for objectKey, valid for expiry. +// +// The object is signed with a canned public-read ACL so it resolves through the +// public CDN once uploaded — which means the app MUST send the returned +// x-amz-acl header on the PUT, since it is part of the signature. Content-Type +// is deliberately left unsigned so the app may send it (or not) without +// invalidating the URL. +func PresignPut(objectKey, contentType string, expiry time.Duration) (*PresignedUpload, error) { + cfg := loadSpacesConfig() + if cfg.accessKey == "" || cfg.secretKey == "" || cfg.bucket == "" { + return nil, fmt.Errorf("object storage not configured") + } + + const ( + service = "s3" + algorithm = "AWS4-HMAC-SHA256" + acl = "public-read" + ) + + // Virtual-hosted-style host: bucket.region-endpoint. Spaces supports it and + // it keeps the bucket out of the canonical path. + host := cfg.bucket + "." + cfg.endpoint + + now := time.Now().UTC() + amzDate := now.Format("20060102T150405Z") + dateStamp := now.Format("20060102") + expSecs := int(expiry.Seconds()) + if expSecs <= 0 { + expSecs = 600 + } + + // Canonical URI: each key segment RFC3986-encoded, "/" preserved. + canonicalURI := "/" + encodePath(objectKey) + + credentialScope := dateStamp + "/" + cfg.region + "/" + service + "/aws4_request" + credential := cfg.accessKey + "/" + credentialScope + + // SignedHeaders covers host and the canned ACL; the app echoes x-amz-acl. + signedHeaders := "host;x-amz-acl" + + // Canonical query string: the five presign params, sorted, RFC3986-encoded + // (including the "/" in the credential, which must become %2F). + q := [][2]string{ + {"X-Amz-Algorithm", algorithm}, + {"X-Amz-Credential", credential}, + {"X-Amz-Date", amzDate}, + {"X-Amz-Expires", fmt.Sprintf("%d", expSecs)}, + {"X-Amz-SignedHeaders", signedHeaders}, + } + canonicalQuery := canonicalizeQuery(q) + + canonicalHeaders := "host:" + host + "\n" + "x-amz-acl:" + acl + "\n" + + canonicalRequest := strings.Join([]string{ + "PUT", + canonicalURI, + canonicalQuery, + canonicalHeaders, + signedHeaders, + "UNSIGNED-PAYLOAD", + }, "\n") + + stringToSign := strings.Join([]string{ + algorithm, + amzDate, + credentialScope, + hexSHA256(canonicalRequest), + }, "\n") + + signingKey := deriveSigningKey(cfg.secretKey, dateStamp, cfg.region, service) + signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign)) + + uploadURL := "https://" + host + canonicalURI + "?" + canonicalQuery + + "&X-Amz-Signature=" + signature + + headers := map[string]string{"x-amz-acl": acl} + if contentType != "" { + headers["Content-Type"] = contentType + } + + return &PresignedUpload{ + UploadURL: uploadURL, + URL: cfg.cdnBase + "/" + objectKey, + Method: "PUT", + Headers: headers, + Key: objectKey, + ExpiresIn: expSecs, + }, nil +} + +// deriveSigningKey builds the SigV4 signing key: HMAC chained over the date, +// region, service and the "aws4_request" terminator. +func deriveSigningKey(secret, dateStamp, region, service string) []byte { + kDate := hmacSHA256([]byte("AWS4"+secret), dateStamp) + kRegion := hmacSHA256(kDate, region) + kService := hmacSHA256(kRegion, service) + return hmacSHA256(kService, "aws4_request") +} + +func hmacSHA256(key []byte, data string) []byte { + h := hmac.New(sha256.New, key) + h.Write([]byte(data)) + return h.Sum(nil) +} + +func hexSHA256(data string) string { + sum := sha256.Sum256([]byte(data)) + return hex.EncodeToString(sum[:]) +} + +// canonicalizeQuery encodes and sorts query pairs per SigV4. The input is +// already in sorted key order (the five X-Amz-* params), so this only encodes. +func canonicalizeQuery(pairs [][2]string) string { + parts := make([]string, 0, len(pairs)) + for _, p := range pairs { + parts = append(parts, awsEncode(p[0], true)+"="+awsEncode(p[1], true)) + } + return strings.Join(parts, "&") +} + +// encodePath encodes an object key for the canonical URI, preserving the "/" +// path separators while encoding everything else per RFC3986. +func encodePath(key string) string { + segs := strings.Split(key, "/") + for i, s := range segs { + segs[i] = awsEncode(s, false) + } + return strings.Join(segs, "/") +} + +// awsEncode applies AWS's RFC3986 encoding: unreserved characters pass through, +// everything else becomes %XX. When encodeSlash is false "/" is left as-is (for +// path segments already split on it). +func awsEncode(s string, encodeSlash bool) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + ch := s[i] + switch { + case (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || + (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~': + b.WriteByte(ch) + case ch == '/' && !encodeSlash: + b.WriteByte(ch) + default: + b.WriteString(fmt.Sprintf("%%%02X", ch)) + } + } + return b.String() +} diff --git a/routes/routes.go b/routes/routes.go index 2b367ae..d2ecde5 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -174,6 +174,12 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { // Miler's own bookings milerAuth.Get("/bookings", controllers.MilerGetMyBookings) + // Proof-of-delivery / signature upload: hands the app a short-lived presigned + // PUT URL so the Spaces credentials stay server-side (the legacy rider app + // shipped the bucket key). App PUTs the image, then sends back the returned + // public URL as photourl / receiversignatureurl on deliver. + milerAuth.Post("/uploads/sign", controllers.MilerSignUpload) + // Consignment current-state read: lets the app know whether a consignment is // collected / out-for-delivery / delivered without replaying the logs history. milerAuth.Get("/consignments/:consignmentid", controllers.MilerGetConsignment)