package storage import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "io" "net/http" "net/url" "strconv" "time" ) type Signer struct { store Store base *url.URL secret []byte now func() time.Time uploadPolicy *UploadPolicy } func NewSigner(store Store, baseURL string, secret []byte) (*Signer, error) { base, err := url.Parse(baseURL) if err != nil { return nil, err } if store == nil || len(secret) < 32 || base.Host == "" || (base.Scheme != "https" && base.Scheme != "http") || base.RawQuery != "" || base.Fragment != "" || base.User != nil { return nil, errors.New("storage: store, HTTP base URL and >=32-byte secret required") } return &Signer{store: store, base: base, secret: append([]byte(nil), secret...), now: time.Now}, nil } func (s *Signer) signature(key, expiry string) string { mac := hmac.New(sha256.New, s.secret) io.WriteString(mac, "GET\n"+key+"\n"+expiry) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } func (s *Signer) PresignGet(ctx context.Context, key string, ttl time.Duration) (*url.URL, error) { if err := Check(ctx, key); err != nil { return nil, err } if ttl < time.Second || ttl > time.Hour { return nil, errors.New("storage: TTL must be 1s..1h") } expiry := strconv.FormatInt(s.now().Add(ttl).Unix(), 10) u := *s.base u.RawQuery = url.Values{"key": {key}, "expires": {expiry}, "signature": {s.signature(key, expiry)}}.Encode() return &u, nil } // ServeHTTP serves signed downloads as attachments. Issue links only after // authorization; possession of an unexpired URL grants access to its object. func (s *Signer) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPut && s.uploadPolicy != nil { s.serveUpload(w, r) return } if r.Method != "GET" && r.Method != "HEAD" { w.Header().Set("Allow", "GET, HEAD") http.Error(w, "method not allowed", 405) return } q, err := url.ParseQuery(r.URL.RawQuery) if err != nil || len(q) != 3 || len(q["key"]) != 1 || len(q["expires"]) != 1 || len(q["signature"]) != 1 { http.Error(w, "invalid link", 403) return } key, expiry := q.Get("key"), q.Get("expires") expires, err := strconv.ParseInt(expiry, 10, 64) now := s.now().Unix() if err != nil || expires <= now || expires > now+3600 || Check(r.Context(), key) != nil || !hmac.Equal([]byte(q.Get("signature")), []byte(s.signature(key, expiry))) { http.Error(w, "invalid or expired link", 403) return } object, err := s.store.Get(r.Context(), key) if err != nil { status := 500 if errors.Is(err, ErrNotFound) { status = 404 } http.Error(w, "object unavailable", status) return } defer object.Body.Close() contentType := object.ContentType if contentType == "" { contentType = "application/octet-stream" } w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Disposition", "attachment") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Cache-Control", "private, no-store") w.Header().Set("Content-Length", strconv.FormatInt(object.Size, 10)) if r.Method == "GET" { _, _ = io.Copy(w, object.Body) } }