feat: miler POD upload — presigned Spaces PUT (/miler/uploads/sign)

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
This commit is contained in:
Suriyakumarvijayanayagam
2026-08-24 18:18:52 +05:30
parent f6d339a33f
commit b0f733ae38
4 changed files with 357 additions and 0 deletions

10
.env
View File

@@ -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

View File

@@ -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)
}

225
internal/storage/spaces.go Normal file
View File

@@ -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()
}

View File

@@ -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)