Files
doormile_backend/internal/storage/spaces.go
Suriyakumarvijayanayagam b0f733ae38 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
2026-08-24 18:18:52 +05:30

226 lines
7.3 KiB
Go

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