feat(storage): serve media through backend and add local FS backend
- introduce storage.Backend with S3 and local file-system implementations - deliver all blobs via signed GET/HEAD /api/objects/<key> (Range, Content-Type) - stream S3 objects through the backend; remove presigned URL path - share shared HMAC Signer between backends - remove S3_PUBLIC_URL; rename S3_PRESIGN_TTL to OBJECT_URL_TTL - make storage label in health dynamic; nginx proxy_buffering off for objects
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
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
|
||||
baseURL string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewSigner builds a signer. secret signs the URLs (required); baseURL is the
|
||||
// public base URL of the app; ttl limits URL lifetime (defaults to one hour).
|
||||
func NewSigner(baseURL, secret string, ttl time.Duration) (*Signer, error) {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return nil, errors.New("url secret is required")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = time.Hour
|
||||
}
|
||||
return &Signer{
|
||||
secret: []byte(secret),
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
ttl: ttl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// URL returns a signed app URL for the given opaque key.
|
||||
func (s *Signer) URL(key string) string {
|
||||
exp := time.Now().Add(s.ttl).Unix()
|
||||
q := url.Values{}
|
||||
q.Set("e", strconv.FormatInt(exp, 10))
|
||||
q.Set("s", s.sign(key, exp))
|
||||
return fmt.Sprintf("%s/api/objects/%s?%s", s.baseURL, escapeKey(key), q.Encode())
|
||||
}
|
||||
|
||||
// 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, "/")
|
||||
}
|
||||
Reference in New Issue
Block a user