package storage import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "time" ) // Signer produces and verifies the signed app URLs used by the /api/objects // route. It is shared by every storage backend so delivery is identical: the // client always talks to the backend, never to object storage directly. type Signer struct { secret []byte ttl time.Duration window time.Duration now func() time.Time } // NewSigner builds a signer. secret signs the URLs (required); ttl limits URL // lifetime (defaults to one hour); window quantizes the expiry so the same // object yields an identical URL within one window (defaults to ten minutes, and // is capped at ttl). A non-positive window keeps the URL stable for its whole // lifetime. func NewSigner(secret string, ttl, window time.Duration) (*Signer, error) { if strings.TrimSpace(secret) == "" { return nil, errors.New("url secret is required") } if ttl <= 0 { ttl = time.Hour } if window <= 0 { window = 10 * time.Minute } if window > ttl { window = ttl } return &Signer{ secret: []byte(secret), ttl: ttl, window: window, now: time.Now, }, nil } // URL returns a signed object URL for the given opaque key. The URL is // origin-relative (it has no scheme or host) so the client fetches the blob from // the same origin that served the app, regardless of the deployment host. The // expiry is quantized to the signing window so repeated calls within that window // return the identical URL, letting clients reuse cached objects. func (s *Signer) URL(key string) string { exp := s.expiry() q := url.Values{} q.Set("e", strconv.FormatInt(exp, 10)) q.Set("s", s.sign(key, exp)) return fmt.Sprintf("/api/objects/%s?%s", escapeKey(key), q.Encode()) } // expiry is the quantized Unix expiry shared by all URLs issued in the current // window: the window start plus the TTL. It is never earlier than now. func (s *Signer) expiry() int64 { now := s.now() exp := now.Truncate(s.window).Add(s.ttl) if exp.Before(now) { exp = now.Add(s.ttl) } return exp.Unix() } // VerifyRequest checks the signature and expiry carried by an object request. // It returns ErrSignedURLInvalid when the request is not authorized. func (s *Signer) VerifyRequest(key string, r *http.Request) error { q := r.URL.Query() exp, err := strconv.ParseInt(q.Get("e"), 10, 64) if err != nil || !s.verify(key, exp, q.Get("s")) { return ErrSignedURLInvalid } return nil } func (s *Signer) sign(key string, exp int64) string { mac := hmac.New(sha256.New, s.secret) _, _ = fmt.Fprintf(mac, "%s\n%d", key, exp) return hex.EncodeToString(mac.Sum(nil)) } func (s *Signer) verify(key string, exp int64, sig string) bool { if sig == "" || time.Now().Unix() > exp { return false } return hmac.Equal([]byte(s.sign(key, exp)), []byte(sig)) } func escapeKey(key string) string { parts := strings.Split(key, "/") for i, p := range parts { parts[i] = url.PathEscape(p) } return strings.Join(parts, "/") }