300 lines
9.7 KiB
Go
300 lines
9.7 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()
|
|
}
|
|
|
|
// 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
|
|
}
|