feat(storage): support signed read options and local byte ranges

This commit is contained in:
2026-09-30 22:44:54 +08:00
parent 01a03149f9
commit f0c62152d7
29 changed files with 1395 additions and 30 deletions

View File

@@ -151,14 +151,31 @@ func (s *Store) Get(ctx context.Context, key string) (storage.Object, error) {
}
info.ModifiedAt = stat.ModTime()
ok = true
return storage.Object{Info: info, Body: &body{Reader: storage.ContextReader{Context: ctx, Reader: io.LimitReader(f, info.Size)}, Closer: f}}, nil
// Expose only the payload as a seekable section. Seeks are relative to the
// object, never to our private metadata header or other disk bytes.
return storage.Object{Info: info, Body: &body{section: io.NewSectionReader(f, int64(length)+4, info.Size), file: f, ctx: ctx}}, nil
}
type body struct {
io.Reader
io.Closer
section *io.SectionReader
file *os.File
ctx context.Context
}
func (b *body) Read(p []byte) (int, error) {
if err := b.ctx.Err(); err != nil {
return 0, err
}
return b.section.Read(p)
}
func (b *body) Seek(offset int64, whence int) (int64, error) {
if err := b.ctx.Err(); err != nil {
return 0, err
}
return b.section.Seek(offset, whence)
}
func (b *body) Close() error { return b.file.Close() }
func (s *Store) Delete(ctx context.Context, key string) error {
if err := storage.Check(ctx, key); err != nil {
return err

125
storage/local/read_test.go Normal file
View File

@@ -0,0 +1,125 @@
package local
import (
"context"
"io"
"mime"
"mime/multipart"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
)
func TestSignedPayloadRanges(t *testing.T) {
store, err := New(t.TempDir(), 1<<20)
if err != nil {
t.Fatal(err)
}
data := "%PDF-1.4\n" + strings.Repeat("0123456789", 100) + "\n%%EOF\n"
ctx := context.Background()
if _, err = store.Put(ctx, "private/document", strings.NewReader(data), int64(len(data)), "application/pdf"); err != nil {
t.Fatal(err)
}
signer, _ := storage.NewSigner(store, "https://files.example/read", []byte(strings.Repeat("s", 32)))
link, err := signer.PresignGetWithOptions(ctx, "private/document", 30*time.Minute, storage.ReadOptions{Disposition: storage.DispositionInline, FileName: "示例.pdf"})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
method, span string
status int
want string
}{
{"GET", "", 200, data}, {"HEAD", "", 200, ""}, {"HEAD", "bytes=0-3", 200, ""},
{"GET", "bytes=0-7", 206, data[:8]}, {"GET", "bytes=20-", 206, data[20:]}, {"GET", "bytes=-6", 206, data[len(data)-6:]},
{"GET", "bytes=1000-999999", 206, data[1000:]}, {"GET", "bytes=999999-", 416, ""}, {"GET", "bytes=bad", 416, ""},
} {
t.Run(tc.method+tc.span, func(t *testing.T) {
r := httptest.NewRequest(tc.method, link.URL, nil)
if tc.span != "" {
r.Header.Set("Range", tc.span)
}
w := httptest.NewRecorder()
signer.ServeHTTP(w, r)
if w.Code != tc.status {
t.Fatal(w.Code)
}
if tc.status != 416 && w.Body.String() != tc.want {
t.Fatal("wrong payload slice")
}
if tc.status == 206 && (w.Header().Get("Content-Length") != strconv.Itoa(len(tc.want)) || !strings.HasSuffix(w.Header().Get("Content-Range"), "/"+strconv.Itoa(len(data)))) {
t.Fatal(w.Header())
}
if tc.status == 200 && w.Header().Get("Content-Length") != strconv.Itoa(len(data)) {
t.Fatal("metadata included in length")
}
if tc.span == "bytes=999999-" && w.Header().Get("Content-Range") != "bytes */"+strconv.Itoa(len(data)) {
t.Fatal(w.Header())
}
if w.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal("cache policy")
}
if tc.status != 416 {
kind, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
if err != nil || kind != "inline" || params["filename"] != "示例.pdf" || w.Header().Get("Content-Type") != "application/pdf" {
t.Fatal(w.Header(), err)
}
}
})
}
r := httptest.NewRequest("GET", link.URL, nil)
r.Header.Set("Range", "bytes=0-3,10-13")
w := httptest.NewRecorder()
signer.ServeHTTP(w, r)
kind, params, err := mime.ParseMediaType(w.Header().Get("Content-Type"))
if err != nil || w.Code != 206 || kind != "multipart/byteranges" {
t.Fatal(w.Header(), err)
}
reader := multipart.NewReader(w.Body, params["boundary"])
for _, want := range []string{data[:4], data[10:14]} {
p, err := reader.NextPart()
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(p)
if err != nil || string(got) != want {
t.Fatal("multipart bytes", err)
}
}
if _, err := reader.NextPart(); err != io.EOF {
t.Fatal(err)
}
// Random access cannot expose the managed header and still observes context.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
object, err := store.Get(ctx, "private/document")
if err != nil {
t.Fatal(err)
}
defer object.Body.Close()
seeker := object.Body.(io.ReadSeeker)
if size, err := seeker.Seek(0, io.SeekEnd); err != nil || size != int64(len(data)) {
t.Fatal(size, err)
}
if _, err := seeker.Seek(-1, io.SeekStart); err == nil {
t.Fatal("seek into header")
}
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
t.Fatal(err)
}
prefix := make([]byte, 8)
if _, err := io.ReadFull(seeker, prefix); err != nil || string(prefix) != data[:8] {
t.Fatal("metadata exposed", err)
}
cancel()
if _, err := seeker.Seek(0, io.SeekStart); err != context.Canceled {
t.Fatal(err)
}
if _, err := seeker.Read(prefix); err != context.Canceled {
t.Fatal(err)
}
}