feat: Presigned object URLs (s3 driver)

POST /buckets/:id/object-presigns mints SigV4 query-signed GET/PUT
URLs so clients move bytes directly against S3 — large transfers skip
the backend entirely.

- storage.Presigner optional interface; S3Store implements, local
  driver returns a clear error (no external surface to sign).
- TTL 60s–1h (default 15m); download requires read role + existing
  object row (no key probing); upload requires write role.
- S3_PUBLIC_ENDPOINT rewrites the client-facing host for internal
  S3_ENDPOINTs — signatures bind the host, URLs are valid only where
  minted.
- CLI: primora objects:presign <bucket> <key> [--upload] [--ttl].

Verified live against cloudserver: GET fetched the object with no
auth headers; PUT pushed 1 MB direct to S3; unsigned list → 403;
untracked-key download presign → 404.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-24 20:35:21 +02:00
1 parent a84049b8fc
commit fd36389328
22 files changed
+446 -7

No files matched your search

+35
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
@@ -214,3 +215,37 @@ func TestCanonicalQueryRFC3986(t *testing.T) {
t.Fatalf("canonicalQuery = %q, want %q", got, want)
}
}
func TestS3StorePresign(t *testing.T) {
store, _ := newTestS3(t)
ctx := context.Background()
dl, err := store.Presign(ctx, "bucket-a", "docs/x.txt", http.MethodGet, 10*time.Minute)
if err != nil {
t.Fatalf("Presign: %v", err)
}
if dl.Method != http.MethodGet || dl.ExpiresAt.IsZero() {
t.Fatalf("bad presign: %+v", dl)
}
u, err := url.Parse(dl.URL)
if err != nil {
t.Fatalf("url parse: %v", err)
}
q := u.Query()
for _, k := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Expires", "X-Amz-SignedHeaders", "X-Amz-Signature"} {
if q.Get(k) == "" {
t.Fatalf("missing %s in %s", k, dl.URL)
}
}
if q.Get("X-Amz-Expires") != "600" || q.Get("X-Amz-SignedHeaders") != "host" {
t.Fatalf("bad query: %s", dl.URL)
}
if _, err := store.Presign(ctx, "b", "k", http.MethodDelete, 0); err == nil {
t.Fatal("expected method rejection")
}
// TTL cap.
capped, _ := store.Presign(ctx, "b", "k.txt", http.MethodPut, 48*time.Hour)
if cu, _ := url.Parse(capped.URL); cu.Query().Get("X-Amz-Expires") != "3600" {
t.Fatalf("ttl not capped: %s", capped.URL)
}
}