feat(storage): support signed read options and local byte ranges
This commit is contained in:
15
examples/signedRead/README.md
Normal file
15
examples/signedRead/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 通用签名读取
|
||||
|
||||
```sh
|
||||
go run ./examples/signedRead
|
||||
# 或执行真实 HTTP 检查并退出
|
||||
go run ./examples/signedRead -check
|
||||
```
|
||||
|
||||
示例自动创建临时私有存储、一页 PDF 和随机签名密钥,监听随机 loopback 端口。打开终端显示的首页,可预览或下载带中文文件名的 PDF;两条链接从签发起 30 分钟有效。首页显示签名内的实际到期时间,不会通过刷新延长链接;重启会生成新的文件和密钥。Ctrl+C 清理临时目录。
|
||||
|
||||
示例使用 `storage.ReadPresigner` 对应的 `PresignGetWithOptions` 能力,本地 `Signer` 与 S3/R2、OSS 适配器采用同一组 `ReadOptions`。省略 Disposition 默认 attachment,inline 请求浏览器内联展示;实际展示仍由浏览器设置和文件格式支持决定。原 `PresignGet` 接口与既有下载链接保持兼容。
|
||||
|
||||
本地支持 GET、HEAD、Range、206/416,响应范围只包含文件内容。云端由原生存储地址响应,GET 签名不通用于 HEAD;OSS 默认域名可能强制下载,应在 SDK 签名前配置自己的域名。详见 [存储接入说明](../../storage/README.md#签名预览与下载)。
|
||||
|
||||
这只是回环地址上的固定测试文件演示。实际项目先检查权限,再签发指定对象的链接;配置公开签名入口,不能从请求 Host 或转发头猜测。文件路径、权限、审计和页面状态由项目维护。不要记录 URL 查询参数,不公开存储目录。链接到期仅阻止后续读取,不收回已获得的内容;重新签发不会撤销旧链接。
|
||||
183
examples/signedRead/main.go
Normal file
183
examples/signedRead/main.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// signedRead is an offline, loopback-only preview/download demonstration.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage/local"
|
||||
)
|
||||
|
||||
func main() {
|
||||
check := flag.Bool("check", false, "run HTTP checks and exit")
|
||||
flag.Parse()
|
||||
if err := run(*check); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(check bool) error {
|
||||
root, err := os.MkdirTemp("", "go-omni-signed-read-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(root)
|
||||
files, err := local.New(root, 1<<20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content := samplePDF()
|
||||
if _, err = files.Put(context.Background(), "sample.pdf", bytes.NewReader(content), int64(len(content)), "application/pdf"); err != nil {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer listener.Close()
|
||||
baseURL := "http://" + listener.Addr().String()
|
||||
secret := make([]byte, 32)
|
||||
if _, err = rand.Read(secret); err != nil {
|
||||
return err
|
||||
}
|
||||
signer, err := storage.NewSigner(files, baseURL+"/file", secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Production applications authorize the caller before this operation. The
|
||||
// sample intentionally exposes only a generated fixture on loopback.
|
||||
preview, err := signer.PresignGetWithOptions(context.Background(), "sample.pdf", 30*time.Minute, storage.ReadOptions{
|
||||
Disposition: storage.DispositionInline, FileName: "示例报告.pdf", ContentType: "application/pdf",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
download, err := signer.PresignGetWithOptions(context.Background(), "sample.pdf", 30*time.Minute, storage.ReadOptions{FileName: "示例报告.pdf"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page := template.Must(template.New("page").Parse(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>Signed read example</title>
|
||||
<body><h1>文件预览与下载</h1><p><a href="{{.Preview.URL}}">打开示例 PDF</a> · <a href="{{.Download.URL}}">下载示例 PDF</a></p>
|
||||
<p>链接到期时间:{{.Preview.ExpiresAt}}</p><p>只读演示:无数据库、业务身份或公开磁盘目录。</p></body></html>`))
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/file", signer)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" || r.Method != http.MethodGet {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
_ = page.Execute(w, struct{ Preview, Download storage.ReadLink }{preview, download})
|
||||
})
|
||||
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- server.Serve(listener) }()
|
||||
defer server.Close()
|
||||
if check {
|
||||
if err := checkHTTP(preview, download, content); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("signed read: PDF preview, Chinese filename, download, HEAD and Range passed")
|
||||
return nil
|
||||
}
|
||||
// Never print signed URLs or enable request logging containing query strings.
|
||||
fmt.Println("Open " + baseURL + " (Ctrl+C to stop)")
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
select {
|
||||
case err := <-done:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return server.Shutdown(shutdown)
|
||||
}
|
||||
}
|
||||
|
||||
func checkHTTP(preview, download storage.ReadLink, content []byte) error {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
for _, tc := range []struct {
|
||||
link storage.ReadLink
|
||||
method, span, disposition string
|
||||
status int
|
||||
body []byte
|
||||
}{
|
||||
{preview, "GET", "", "inline", 200, content},
|
||||
{preview, "HEAD", "", "inline", 200, nil},
|
||||
{preview, "GET", "bytes=0-7", "inline", 206, content[:8]},
|
||||
{download, "GET", "", "attachment", 200, content},
|
||||
} {
|
||||
req, err := http.NewRequest(tc.method, tc.link.URL, nil)
|
||||
if err != nil {
|
||||
return errors.New("invalid example link")
|
||||
}
|
||||
if tc.span != "" {
|
||||
req.Header.Set("Range", tc.span)
|
||||
}
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("example request failed")
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
kind, params, headerErr := mime.ParseMediaType(response.Header.Get("Content-Disposition"))
|
||||
if err != nil || headerErr != nil || response.StatusCode != tc.status || !bytes.Equal(body, tc.body) || kind != tc.disposition || params["filename"] != "示例报告.pdf" || response.Header.Get("Content-Type") != "application/pdf" {
|
||||
return errors.New("example response did not match signed options")
|
||||
}
|
||||
size := len(tc.body)
|
||||
if tc.method == "HEAD" {
|
||||
size = len(content)
|
||||
}
|
||||
if response.Header.Get("Content-Length") != strconv.Itoa(size) || response.Header.Get("Cache-Control") != "private, no-store" {
|
||||
return errors.New("example size or cache policy mismatch")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A complete, single-page PDF with a real cross-reference table. No generator
|
||||
// dependency or business document is needed for this example.
|
||||
func samplePDF() []byte {
|
||||
stream := "BT /F1 24 Tf 72 720 Td (go-omni signed read) Tj ET\n"
|
||||
objects := []string{
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(stream), stream),
|
||||
}
|
||||
var pdf bytes.Buffer
|
||||
pdf.WriteString("%PDF-1.4\n")
|
||||
offsets := []int{0}
|
||||
for i, object := range objects {
|
||||
offsets = append(offsets, pdf.Len())
|
||||
fmt.Fprintf(&pdf, "%d 0 obj\n%s\nendobj\n", i+1, object)
|
||||
}
|
||||
xref := pdf.Len()
|
||||
fmt.Fprintf(&pdf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
|
||||
for _, offset := range offsets[1:] {
|
||||
fmt.Fprintf(&pdf, "%010d 00000 n \n", offset)
|
||||
}
|
||||
fmt.Fprintf(&pdf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xref)
|
||||
return pdf.Bytes()
|
||||
}
|
||||
9
examples/signedRead/main_test.go
Normal file
9
examples/signedRead/main_test.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSignedReadHTTP(t *testing.T) {
|
||||
if err := run(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user