mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
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:
1 parent
a84049b8fc
commit
fd36389328
22 files changed
+446
-7
No files matched your search
@@ -89,6 +89,7 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
||||
Bucket: cfg.S3Bucket,
|
||||
AccessKeyID: cfg.S3AccessKeyID,
|
||||
SecretAccessKey: cfg.S3SecretAccessKey,
|
||||
PublicEndpoint: cfg.S3PublicEndpoint,
|
||||
Prefix: cfg.S3Prefix,
|
||||
PathStyle: cfg.S3PathStyle,
|
||||
})
|
||||
|
||||
@@ -44,6 +44,7 @@ type Config struct {
|
||||
S3Bucket string
|
||||
S3AccessKeyID string
|
||||
S3SecretAccessKey string
|
||||
S3PublicEndpoint string // presigned-URL host when it differs from Endpoint
|
||||
S3Prefix string
|
||||
S3PathStyle bool
|
||||
|
||||
@@ -86,6 +87,7 @@ func Load() (Config, error) {
|
||||
S3Bucket: os.Getenv("S3_BUCKET"),
|
||||
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
|
||||
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
||||
S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"),
|
||||
S3Prefix: os.Getenv("S3_PREFIX"),
|
||||
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
|
||||
FunctionsMaxBytes: 65536,
|
||||
|
||||
@@ -142,6 +142,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
||||
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
||||
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
|
||||
api.POST("/buckets/:bucketID/object-presigns", h.presignObject)
|
||||
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
|
||||
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
|
||||
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
|
||||
@@ -891,6 +892,39 @@ func (h *HTTPHandler) updateObject(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type presignObjectRequest struct {
|
||||
Key string `json:"key" validate:"required"`
|
||||
Op string `json:"op" validate:"omitempty,oneof=download upload"`
|
||||
TTLSeconds int `json:"ttl_seconds" validate:"omitempty,min=60,max=3600"`
|
||||
}
|
||||
|
||||
// presignObject returns a short-lived URL clients call against storage
|
||||
// directly — keeps large transfers off the backend. S3 driver only.
|
||||
func (h *HTTPHandler) presignObject(c *gin.Context) {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
bucketID, ok := parseUUIDParam(c, "bucketID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body presignObjectRequest
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
out, err := h.Platform.PresignObject(c.Request.Context(), actor, bucketID, strings.TrimSpace(body.Key), body.Op, body.TTLSeconds)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
|
||||
return
|
||||
}
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteObject(c *gin.Context) {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -28,7 +28,11 @@ type S3Config struct {
|
||||
SecretAccessKey string
|
||||
Prefix string // optional key prefix, e.g. "primora/"
|
||||
PathStyle bool // bucket/key path style (MinIO, Garage) vs virtual-hosted
|
||||
HTTPClient *http.Client
|
||||
// PublicEndpoint rewrites presigned-URL hosts for clients that can't
|
||||
// reach Endpoint (e.g. minio:9000 inside compose). Signature binds the
|
||||
// public host — the URL is only valid there.
|
||||
PublicEndpoint string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// S3Store implements Store against an S3-compatible API using stdlib-only
|
||||
@@ -271,6 +275,88 @@ func (s *S3Store) DeleteBucket(bucketID string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Presign mints a SigV4 query-signed URL for a single GET/PUT on the
|
||||
// object — clients talk to S3 directly instead of proxying bytes through
|
||||
// the backend. TTL is capped at one hour.
|
||||
func (s *S3Store) Presign(ctx context.Context, bucketID, objectKey, method string, ttl time.Duration) (*PresignedURL, error) {
|
||||
if method != http.MethodGet && method != http.MethodPut {
|
||||
return nil, fmt.Errorf("s3 presign: unsupported method %q", method)
|
||||
}
|
||||
key, err := s.objectKey(bucketID, objectKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 15 * time.Minute
|
||||
}
|
||||
if ttl > time.Hour {
|
||||
ttl = time.Hour
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
scope := fmt.Sprintf("%s/%s/s3/aws4_request", dateStamp, s.cfg.Region)
|
||||
|
||||
// Build the unsigned URL — same path/vhost logic as signedRequest, but
|
||||
// on the client-facing endpoint when one is configured.
|
||||
var base *url.URL
|
||||
endpoint := strings.TrimSuffix(s.cfg.PublicEndpoint, "/")
|
||||
if endpoint == "" {
|
||||
endpoint = strings.TrimSuffix(s.cfg.Endpoint, "/")
|
||||
}
|
||||
if s.cfg.PathStyle {
|
||||
u, err := url.Parse(endpoint + "/" + s.cfg.Bucket + "/" + escapeKey(key))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("s3 presign url: %w", err)
|
||||
}
|
||||
base = u
|
||||
} else {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("s3 presign url: %w", err)
|
||||
}
|
||||
u.Host = s.cfg.Bucket + "." + u.Host
|
||||
u.Path = "/" + escapeKey(key)
|
||||
base = u
|
||||
}
|
||||
|
||||
q := base.Query()
|
||||
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
|
||||
q.Set("X-Amz-Credential", s.cfg.AccessKeyID+"/"+scope)
|
||||
q.Set("X-Amz-Date", amzDate)
|
||||
q.Set("X-Amz-Expires", fmt.Sprintf("%d", int64(ttl.Seconds())))
|
||||
q.Set("X-Amz-SignedHeaders", "host")
|
||||
|
||||
canonicalURI := base.EscapedPath()
|
||||
if canonicalURI == "" {
|
||||
canonicalURI = "/"
|
||||
}
|
||||
canonicalRequest := strings.Join([]string{
|
||||
method,
|
||||
canonicalURI,
|
||||
canonicalQuery(q),
|
||||
"host:" + base.Host + "\n",
|
||||
"host",
|
||||
"UNSIGNED-PAYLOAD",
|
||||
}, "\n")
|
||||
stringToSign := strings.Join([]string{
|
||||
"AWS4-HMAC-SHA256",
|
||||
amzDate,
|
||||
scope,
|
||||
hexSHA256(canonicalRequest),
|
||||
}, "\n")
|
||||
|
||||
kDate := hmacSHA256([]byte("AWS4"+s.cfg.SecretAccessKey), dateStamp)
|
||||
kRegion := hmacSHA256(kDate, s.cfg.Region)
|
||||
kService := hmacSHA256(kRegion, "s3")
|
||||
kSigning := hmacSHA256(kService, "aws4_request")
|
||||
q.Set("X-Amz-Signature", hex.EncodeToString(hmacSHA256(kSigning, stringToSign)))
|
||||
base.RawQuery = q.Encode()
|
||||
|
||||
return &PresignedURL{URL: base.String(), Method: method, ExpiresAt: now.Add(ttl)}, nil
|
||||
}
|
||||
|
||||
type listResult struct {
|
||||
Contents []struct{ Key string `xml:"Key"` } `xml:"Contents"`
|
||||
NextContinuationToken string `xml:"NextContinuationToken"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store is the object-storage contract the platform service depends on.
|
||||
@@ -14,3 +15,17 @@ type Store interface {
|
||||
MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error)
|
||||
DeleteBucket(bucketID string) error
|
||||
}
|
||||
|
||||
// PresignedURL is a short-lived direct-to-storage URL for one operation.
|
||||
type PresignedURL struct {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// Presigner is an optional capability: stores backed by a real object API
|
||||
// (S3) can mint URLs clients call directly. The local driver can't — its
|
||||
// "URL" would have to be served by this backend anyway.
|
||||
type Presigner interface {
|
||||
Presign(ctx context.Context, bucketID, objectKey, method string, ttl time.Duration) (*PresignedURL, error)
|
||||
}
|
||||
@@ -677,6 +677,37 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BucketObject"
|
||||
/buckets/{bucketID}/object-presigns:
|
||||
post:
|
||||
tags: [Storage]
|
||||
operationId: presignBucketObject
|
||||
description: >-
|
||||
Mint a short-lived URL the client calls against object storage
|
||||
directly — keeps large transfers off the backend. Requires
|
||||
BACKEND_STORAGE_DRIVER=s3; the URL's host comes from
|
||||
S3_PUBLIC_ENDPOINT (or S3_ENDPOINT when unset). Signatures bind the
|
||||
host — a presigned URL is valid only at the endpoint it was minted
|
||||
for.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/BucketID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PresignObjectRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Presigned URL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PresignedURL"
|
||||
"404":
|
||||
description: Bucket or object not found (download)
|
||||
/buckets/{bucketID}:
|
||||
patch:
|
||||
tags: [Storage]
|
||||
@@ -3130,6 +3161,36 @@ components:
|
||||
format: uuid
|
||||
nullable: true
|
||||
required: [objectKey, newObjectKey]
|
||||
PresignObjectRequest:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: Object key inside the bucket.
|
||||
op:
|
||||
type: string
|
||||
enum: [download, upload]
|
||||
default: download
|
||||
description: download → GET URL (object must exist); upload → PUT URL (creates/overwrites).
|
||||
ttl_seconds:
|
||||
type: integer
|
||||
minimum: 60
|
||||
maximum: 3600
|
||||
default: 900
|
||||
required: [key]
|
||||
PresignedURL:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
description: Fully-signed URL — call it with the returned method, no auth header needed.
|
||||
method:
|
||||
type: string
|
||||
enum: [GET, PUT]
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [url, method, expires_at]
|
||||
PaginationMetadata:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
cmdObjectsDownload,
|
||||
cmdObjectsList,
|
||||
cmdObjectsRemove,
|
||||
cmdObjectsPresign,
|
||||
cmdObjectsUpload,
|
||||
} from "./commands/objects.js";
|
||||
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
|
||||
@@ -114,6 +115,12 @@ cli
|
||||
.command("objects:rm <bucket> <key>", "Delete an object")
|
||||
.option("--project <id>", "Project override")
|
||||
.action(cmdObjectsRemove);
|
||||
cli
|
||||
.command("objects:presign <bucket> <key>", "Mint a presigned URL (s3 driver)")
|
||||
.option("--project <id>", "Project override")
|
||||
.option("--upload", "PUT URL for direct upload instead of GET download")
|
||||
.option("--ttl <seconds>", "Lifetime 60-3600 (default 900)")
|
||||
.action(cmdObjectsPresign);
|
||||
|
||||
cli
|
||||
.command("keys:list", "List API keys for the current project")
|
||||
|
||||
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
|
||||
import { StorageService } from "@primora/api-client";
|
||||
import { PresignObjectRequest, StorageService } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import {
|
||||
@@ -133,3 +133,31 @@ export async function cmdObjectsRemove(
|
||||
await StorageService.deleteBucketObject({ bucketId, objectKey });
|
||||
success(`Deleted ${objectKey}`);
|
||||
}
|
||||
|
||||
export async function cmdObjectsPresign(
|
||||
bucketRef: string,
|
||||
objectKey: string,
|
||||
options: { project?: string; upload?: boolean; ttl?: string; json?: boolean },
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
const bucketId = await bucketIdFor(cfg, bucketRef, options.project);
|
||||
const presigned = await StorageService.presignBucketObject({
|
||||
bucketId,
|
||||
requestBody: {
|
||||
key: objectKey,
|
||||
op: options.upload
|
||||
? PresignObjectRequest.op.UPLOAD
|
||||
: PresignObjectRequest.op.DOWNLOAD,
|
||||
ttl_seconds: options.ttl ? Number(options.ttl) : undefined,
|
||||
},
|
||||
});
|
||||
if (isJson(options)) {
|
||||
printJson(presigned);
|
||||
return;
|
||||
}
|
||||
console.log(presigned.url);
|
||||
console.error(`# ${presigned.method} — expires ${presigned.expires_at}`);
|
||||
}
|
||||
Reference in new issue
Block a user