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

@@ -10,6 +10,7 @@ import (
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"path/filepath"
"strings"
@@ -1418,6 +1419,48 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu
return object, file, nil
}
// PresignObject mints a short-lived direct-to-storage URL for a single
// object. Requires the s3 storage driver — the local store has no external
// URL surface to sign against.
func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, op string, ttlSeconds int) (*storage.PresignedURL, error) {
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return nil, err
}
method := ""
switch op {
case "download", "":
method = http.MethodGet
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
return nil, err
}
if !objectExists(ctx, s, bucketID, objectKey) {
return nil, pgx.ErrNoRows
}
case "upload":
method = http.MethodPut
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("invalid op %q — download or upload", op)
}
p, ok := s.store.(storage.Presigner)
if !ok {
return nil, fmt.Errorf("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
}
ttl := time.Duration(ttlSeconds) * time.Second
return p.Presign(ctx, bucketID.String(), objectKey, method, ttl)
}
func objectExists(ctx context.Context, s *PlatformService, bucketID uuid.UUID, key string) bool {
_, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
BucketID: bucketID,
ObjectKey: key,
})
return err == nil
}
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {