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:
10
.env
10
.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
|
||||
|
||||
116
controllers/uploadController.go
Normal file
116
controllers/uploadController.go
Normal 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
225
internal/storage/spaces.go
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user