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:
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()
|
||||
}
|
||||
Reference in New Issue
Block a user