feat(storage): support signed read options and local byte ranges
This commit is contained in:
@@ -49,30 +49,88 @@ func (s *Signer) PresignGet(ctx context.Context, key string, ttl time.Duration)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// ServeHTTP serves signed downloads as attachments. Issue links only after
|
||||
func (s *Signer) readSignature(q url.Values) string {
|
||||
values := make(url.Values, len(q))
|
||||
for key, value := range q {
|
||||
if key != "signature" {
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
io.WriteString(mac, "GET-OPTIONS-v2\n"+values.Encode())
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *Signer) PresignGetWithOptions(ctx context.Context, key string, ttl time.Duration, options ReadOptions) (ReadLink, error) {
|
||||
if err := Check(ctx, key); err != nil {
|
||||
return ReadLink{}, err
|
||||
}
|
||||
if ttl < time.Second || ttl > time.Hour {
|
||||
return ReadLink{}, errors.New("storage: TTL must be 1s..1h")
|
||||
}
|
||||
headers, err := options.ResponseHeaders()
|
||||
if err != nil {
|
||||
return ReadLink{}, err
|
||||
}
|
||||
if options.Disposition == "" {
|
||||
options.Disposition = DispositionAttachment
|
||||
}
|
||||
expires := s.now().Add(ttl).UTC().Truncate(time.Second)
|
||||
q := url.Values{"v": {"2"}, "key": {key}, "expires": {strconv.FormatInt(expires.Unix(), 10)}, "disposition": {options.Disposition}, "filename": {options.FileName}, "contentType": {headers.Get("Content-Type")}}
|
||||
q.Set("signature", s.readSignature(q))
|
||||
u := *s.base
|
||||
u.RawQuery = q.Encode()
|
||||
return ReadLink{URL: u.String(), ExpiresAt: expires}, nil
|
||||
}
|
||||
|
||||
// ServeHTTP serves signed GET/HEAD requests, with optional preview headers.
|
||||
// Seekable object bodies support Range and conditional reads; other stores keep
|
||||
// streaming full responses. No full-object buffer is used. 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
|
||||
}
|
||||
w = &readResponseWriter{ResponseWriter: w, head: r.Method == http.MethodHead}
|
||||
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 {
|
||||
keys := []string{"key", "expires", "signature"}
|
||||
if q.Get("v") == "2" {
|
||||
keys = append(keys, "v", "disposition", "filename", "contentType")
|
||||
}
|
||||
valid := err == nil && len(q) == len(keys)
|
||||
for _, key := range keys {
|
||||
valid = valid && len(q[key]) == 1
|
||||
}
|
||||
if !valid {
|
||||
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))) {
|
||||
expected := s.signature(key, expiry)
|
||||
if q.Get("v") == "2" {
|
||||
expected = s.readSignature(q)
|
||||
}
|
||||
if err != nil || expires <= now || expires > now+3600 || Check(r.Context(), key) != nil || !hmac.Equal([]byte(q.Get("signature")), []byte(expected)) {
|
||||
http.Error(w, "invalid or expired link", 403)
|
||||
return
|
||||
}
|
||||
options := ReadOptions{}
|
||||
if q.Get("v") == "2" {
|
||||
options = ReadOptions{Disposition: q.Get("disposition"), FileName: q.Get("filename"), ContentType: q.Get("contentType")}
|
||||
}
|
||||
headers, err := options.ResponseHeaders()
|
||||
if err != nil {
|
||||
http.Error(w, "invalid link", 403)
|
||||
return
|
||||
}
|
||||
object, err := s.store.Get(r.Context(), key)
|
||||
if err != nil {
|
||||
status := 500
|
||||
@@ -82,17 +140,66 @@ func (s *Signer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "object unavailable", status)
|
||||
return
|
||||
}
|
||||
if object.Body == nil {
|
||||
http.Error(w, "object unavailable", 500)
|
||||
return
|
||||
}
|
||||
defer object.Body.Close()
|
||||
if object.Size < 0 {
|
||||
http.Error(w, "object unavailable", 500)
|
||||
return
|
||||
}
|
||||
contentType := object.ContentType
|
||||
if options.ContentType != "" {
|
||||
contentType = headers.Get("Content-Type")
|
||||
}
|
||||
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-Disposition", headers.Get("Content-Disposition"))
|
||||
if body, ok := object.Body.(io.ReadSeeker); ok {
|
||||
// Range describes a GET representation. HEAD reports full-object headers.
|
||||
if r.Method == http.MethodHead {
|
||||
r = r.Clone(r.Context())
|
||||
r.Header.Del("Range")
|
||||
}
|
||||
http.ServeContent(w, r, "", object.ModifiedAt, body)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(object.Size, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == "GET" {
|
||||
_, _ = io.Copy(w, object.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeContent may strip cache headers on errors in newer Go versions. Restore
|
||||
// the signed-link policy at the actual write, including 304/416 and HEAD errors.
|
||||
type readResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
head, wrote bool
|
||||
}
|
||||
|
||||
func (w *readResponseWriter) WriteHeader(status int) {
|
||||
if w.wrote {
|
||||
return
|
||||
}
|
||||
w.wrote = true
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
func (w *readResponseWriter) Write(p []byte) (int, error) {
|
||||
if !w.wrote {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if w.head {
|
||||
return len(p), nil
|
||||
}
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
func (w *readResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
|
||||
var _ Presigner = (*Signer)(nil)
|
||||
var _ ReadPresigner = (*Signer)(nil)
|
||||
|
||||
Reference in New Issue
Block a user