// 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() } // PresignGet issues a short-lived, signed GET URL for one object. // // Parcel photographs are shown to the customer on the receipt, and a parcel // photograph frames the inside of someone's doorway. A permanent CDN link to // one is a permanent link anybody who ever saw it can keep, so the customer // surface serves these through a signature that expires instead. // // Falls back to the plain CDN URL when the bucket credentials are not // configured: an unsigned photo the customer can see beats a receipt with a // missing image, and the objects are currently written public-read anyway. // Once parcel photos are switched to a private ACL this becomes the only way // to read one — which is the point of routing them through here now. func PresignGet(objectKey string, expiry time.Duration) (string, error) { cfg := loadSpacesConfig() if cfg.accessKey == "" || cfg.secretKey == "" || cfg.bucket == "" { if cfg.cdnBase != "" { return cfg.cdnBase + "/" + objectKey, nil } return "", fmt.Errorf("object storage not configured") } const ( service = "s3" algorithm = "AWS4-HMAC-SHA256" ) 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 = 900 } canonicalURI := "/" + encodePath(objectKey) credentialScope := dateStamp + "/" + cfg.region + "/" + service + "/aws4_request" credential := cfg.accessKey + "/" + credentialScope signedHeaders := "host" 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" canonicalRequest := strings.Join([]string{ "GET", 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)) return "https://" + host + canonicalURI + "?" + canonicalQuery + "&X-Amz-Signature=" + signature, nil }