From 256307d4991cf9963d137d59a6665cfe22c5a2da Mon Sep 17 00:00:00 2001 From: Rhaqim Date: Mon, 31 Aug 2026 17:50:54 +0100 Subject: [PATCH 1/5] presigned url for clould --- CHANGELOG.md | 20 +++++++++++++++++ README.md | 29 ++++++++++++++++++++++++ buckt.go | 44 +++++++++++++++++++++++++++++++++++++ buckt_errors.go | 1 + buckt_presign_test.go | 24 ++++++++++++++++++++ client/web/app/api.go | 33 ++++++++++++++++++++++++++++ client/web/domain/api.go | 3 +++ client/web/router/router.go | 1 + cloud/aws/backend.go | 27 +++++++++++++++++++++++ cloud/aws/presign_test.go | 40 +++++++++++++++++++++++++++++++++ go.work.sum | 6 +++++ internal/backend/metered.go | 15 +++++++++++++ internal/domain/backend.go | 14 ++++++++++++ pkg/buckterr/buckterr.go | 5 +++++ 14 files changed, 262 insertions(+) create mode 100644 buckt_presign_test.go create mode 100644 cloud/aws/presign_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c53e3d6..6b2a479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to buckt are documented here. This project follows ## [1.10.0] — unreleased +A backward-compatible **minor** release adding presigned (direct-download) URLs. +Additive; no API removals. + +### ✨ Added + +- **Presigned URLs** (`Client.PresignedURL` / `PresignedURLContext`, + `PresignedDerivativeURL` / `…Context`, and the `domain.PresignBackend` + capability). Mint a time-limited URL that downloads a file (or an image + derivative) **directly from the storage backend**, so reads bypass the + application process entirely — hand the URL to a browser or CDN instead of + streaming bytes through your server. Implemented for the S3/R2 backend + (`cloud/aws`), designed as an optional capability so Azure/GCS can follow; + backends that can't presign (the local filesystem, or during a migration) + return the new `ErrUnsupported`. For an S3/R2 object the correct key form is + resolved first, so a URL for a migrated object doesn't 404. The web client + adds `GET /presign/:file_id?ttl=15m` (501 when unsupported). Presigned URLs + bypass buckt's auth for their lifetime — keep the TTL short. + +## [1.9.1] — unreleased + A backward-compatible **minor** release adding file expiry / temp files. Additive; no API removals. diff --git a/README.md b/README.md index 8853f2f..8a8cde5 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ In fact, Buckt can use MinIO as its storage backend, allowing you to combine Min - [Quick Start](#quick-start) - [Configuration](#configuration) - [All Options](#all-options) + - [Presigned URLs](#presigned-urls) - [Storage Backends](#storage-backends) - [Local Filesystem](#local-filesystem) - [AWS S3](#aws-s3) @@ -241,6 +242,31 @@ Or with options: --- +## Presigned URLs + +Hand clients a time-limited URL that downloads a file **directly from the storage backend**, so reads don't stream through your process — the standard, CDN-friendly way to serve media (images, video, downloads). + +```go + // 15-minute direct-download link for the file's bytes: + url, err := client.PresignedURL(fileID, 15*time.Minute) + + // ...or for a generated image derivative (thumbnail, etc.): + thumbURL, err := client.PresignedDerivativeURL(fileID, "thumbnail", 15*time.Minute) +``` + +Presigning is a **cloud-backend capability**: it works with S3/R2 (`cloud/aws`) today (Azure/GCS can follow). The local filesystem backend — and a Client mid-migration — can't presign, and return `ErrUnsupported`: + +```go + url, err := client.PresignedURL(fileID, ttl) + if errors.Is(err, buckt.ErrUnsupported) { + // fall back to streaming via GetFileStream / the /serve handler + } +``` + +> A presigned URL grants access to whoever holds it — bypassing buckt's own auth — for the whole TTL, so keep the TTL short and don't log the URLs. The web client exposes `GET /presign/:file_id?ttl=15m` (returns 501 when the backend can't presign). + +--- + ## Storage Backends Buckt's `FileBackend` interface lets you swap storage providers without changing your application code. The cloud backends are separate Go modules, so you only pull in the SDKs you actually use. @@ -804,6 +830,8 @@ UploadFile(userID, parentID, name, contentType string, data []byte) (string, err UploadFileFromReader(userID, parentID, name, contentType string, r io.Reader) (string, error) GetFile(fileID string) (*FileModel, error) GetFileStream(fileID string) (*FileModel, io.ReadCloser, error) +PresignedURL(fileID string, ttl time.Duration) (string, error) // direct-download URL (cloud backends) +PresignedDerivativeURL(fileID, name string, ttl time.Duration) (string, error) // direct URL for a derivative ListFiles(folderID string) ([]FileModel, error) ListFilesMetadata(folderID string) ([]FileModel, error) MoveFile(fileID, newParentID string) error @@ -848,6 +876,7 @@ Branch on failures with `errors.Is` using the re-exported sentinels (also availa | `buckt.ErrUploadRejected` | Rejected by an upload scanner | 422 | | `buckt.ErrTrashBatchExceeded` | Folder delete exceeds the trash batch cap | 409 | | `buckt.ErrBackendUnavailable` | Backend unreachable / feature not enabled | 503 | +| `buckt.ErrUnsupported` | Operation not supported by the active backend (e.g. presign on local) | 501 | --- diff --git a/buckt.go b/buckt.go index 10faa20..2b9e6d9 100644 --- a/buckt.go +++ b/buckt.go @@ -1156,6 +1156,50 @@ func (b *Client) startExpirySweeper(interval time.Duration) { }() } +/* Presigned URLs */ + +// PresignedURL returns a time-limited URL that downloads the file's bytes +// directly from the storage backend, so reads bypass this process entirely — +// ideal for serving media (hand the URL to a browser/CDN instead of streaming +// through your server). Valid for ttl. +// +// Only cloud backends can presign. With the local filesystem backend, or while +// a migration is in progress, this returns ErrUnsupported. The URL grants access +// for its whole lifetime regardless of buckt's own auth, so keep ttl short. +func (b *Client) PresignedURL(file_id string, ttl time.Duration) (string, error) { + return b.PresignedURLContext(context.Background(), file_id, ttl) +} + +// PresignedURLContext is PresignedURL with an explicit context. +func (b *Client) PresignedURLContext(ctx context.Context, file_id string, ttl time.Duration) (string, error) { + p, ok := b.backend.(domain.PresignBackend) + if !ok { + return "", fmt.Errorf("backend %q does not support presigned URLs: %w", b.backend.Name(), ErrUnsupported) + } + file, err := b.fileService.GetFile(ctx, file_id) + if err != nil { + return "", err + } + return p.PresignGetURL(ctx, file.Path, ttl) +} + +// PresignedDerivativeURL returns a time-limited direct-download URL for a +// generated image derivative (e.g. "thumbnail"), valid for ttl. Like +// PresignedURL it needs a cloud backend (else ErrUnsupported); the URL 404s if +// the derivative hasn't been generated. See GenerateDerivatives. +func (b *Client) PresignedDerivativeURL(file_id, name string, ttl time.Duration) (string, error) { + return b.PresignedDerivativeURLContext(context.Background(), file_id, name, ttl) +} + +// PresignedDerivativeURLContext is PresignedDerivativeURL with an explicit context. +func (b *Client) PresignedDerivativeURLContext(ctx context.Context, file_id, name string, ttl time.Duration) (string, error) { + p, ok := b.backend.(domain.PresignBackend) + if !ok { + return "", fmt.Errorf("backend %q does not support presigned URLs: %w", b.backend.Name(), ErrUnsupported) + } + return p.PresignGetURL(ctx, derivativeKey(file_id, name), ttl) +} + /* Helper Methods */ func initializeCache(conf CacheConfig, bucktLog domain.BucktLogger) (domain.CacheManager, domain.LRUCache) { diff --git a/buckt_errors.go b/buckt_errors.go index 622f3b0..fad74e3 100644 --- a/buckt_errors.go +++ b/buckt_errors.go @@ -21,4 +21,5 @@ var ( ErrTrashBatchExceeded = buckterr.ErrTrashBatchExceeded ErrBackendUnavailable = buckterr.ErrBackendUnavailable ErrUploadRejected = buckterr.ErrUploadRejected + ErrUnsupported = buckterr.ErrUnsupported ) diff --git a/buckt_presign_test.go b/buckt_presign_test.go new file mode 100644 index 0000000..e8ad406 --- /dev/null +++ b/buckt_presign_test.go @@ -0,0 +1,24 @@ +package buckt + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPresignedURL_UnsupportedOnLocal verifies that presigning is reported as +// unsupported (not a crash) on the local filesystem backend, which can't mint +// direct URLs. Cloud presigning is covered in cloud/aws (no network needed). +func TestPresignedURL_UnsupportedOnLocal(t *testing.T) { + c := newTestClient(t) // local backend + fileID, err := c.UploadFile("u1", "", "a.txt", "text/plain", []byte("x")) + require.NoError(t, err) + + _, err = c.PresignedURL(fileID, 15*time.Minute) + assert.ErrorIs(t, err, ErrUnsupported, "local backend cannot presign") + + _, err = c.PresignedDerivativeURL(fileID, "thumbnail", 15*time.Minute) + assert.ErrorIs(t, err, ErrUnsupported) +} diff --git a/client/web/app/api.go b/client/web/app/api.go index 080bb28..02d9a7b 100644 --- a/client/web/app/api.go +++ b/client/web/app/api.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "io" "time" @@ -578,6 +579,38 @@ func (svc *APIService) SetExpiry(c *gin.Context) { c.JSON(200, response.Success("expiry set")) } +// Presign returns a time-limited URL that downloads the file directly from the +// storage backend (so reads bypass this process). The optional `ttl` query is a +// Go duration (default 15m). Returns 501 when the backend can't presign (e.g. +// the local filesystem). +func (svc *APIService) Presign(c *gin.Context) { + fileID, ok := requireParam(c, "file_id") + if !ok { + return + } + + ttl := 15 * time.Minute + if ttlStr := c.Query("ttl"); ttlStr != "" { + d, err := time.ParseDuration(ttlStr) + if err != nil { + c.AbortWithStatusJSON(400, response.Error("invalid ttl (use a Go duration like 15m)", err.Error())) + return + } + ttl = d + } + + url, err := svc.client.PresignedURLContext(c.Request.Context(), fileID, ttl) + if err != nil { + if errors.Is(err, buckt.ErrUnsupported) { + c.AbortWithStatusJSON(501, response.WrapError("this backend cannot presign URLs", err)) + return + } + abort500(c, "failed to presign url", err) + return + } + c.JSON(200, gin.H{"status": "success", "url": url}) +} + // PurgeExpired permanently deletes every file whose expiry has passed and // reports how many were removed. func (svc *APIService) PurgeExpired(c *gin.Context) { diff --git a/client/web/domain/api.go b/client/web/domain/api.go index 19f01d3..8e070c0 100644 --- a/client/web/domain/api.go +++ b/client/web/domain/api.go @@ -18,6 +18,9 @@ type APIService interface { DeleteFile(c *gin.Context) DeleteFilePermanently(c *gin.Context) + // Presign returns a time-limited direct-download URL for a file. + Presign(c *gin.Context) + // SetExpiry sets or clears a file's automatic-deletion time (ttl or absolute at). SetExpiry(c *gin.Context) // PurgeExpired permanently deletes every file whose expiry has passed. diff --git a/client/web/router/router.go b/client/web/router/router.go index a772a8d..62eb053 100644 --- a/client/web/router/router.go +++ b/client/web/router/router.go @@ -123,6 +123,7 @@ func (r *Router) registerAPIRoutes() { { r.POST("/upload", r.APIService.UploadFile) r.GET("/download/:file_id", r.APIService.DownloadFile) + r.GET("/presign/:file_id", r.APIService.Presign) r.DELETE("/delete/:file_id", r.APIService.DeleteFile) r.DELETE("/scrub/:file_id", r.APIService.DeleteFilePermanently) r.PUT("/expiry/:file_id", r.APIService.SetExpiry) diff --git a/cloud/aws/backend.go b/cloud/aws/backend.go index 8f91caa..33181af 100644 --- a/cloud/aws/backend.go +++ b/cloud/aws/backend.go @@ -284,6 +284,33 @@ func (s *S3Backend) headExists(ctx context.Context, key string) (bool, error) { return true, nil } +// PresignGetURL returns a time-limited URL that downloads the object directly +// from S3/R2, so reads can bypass the application process. The signature is +// computed locally (no network call) — except that, for a nested-mode key +// (leading slash), it may HEAD to resolve which key form the object actually +// lives under, so a URL for a migrated object doesn't 404. See altKey. +func (s *S3Backend) PresignGetURL(ctx context.Context, path string, ttl time.Duration) (string, error) { + key := path + if alt, ok := altKey(path); ok { + // Objects written directly live at path; a local->S3 migration copies + // them under the slash-stripped key. Sign whichever one exists. + if exists, _ := s.headExists(ctx, path); !exists { + if altExists, _ := s.headExists(ctx, alt); altExists { + key = alt + } + } + } + + req, err := s3.NewPresignClient(s.client).PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucketName), + Key: aws.String(key), + }, s3.WithPresignExpires(ttl)) + if err != nil { + return "", fmt.Errorf("failed to presign GET for %s: %w", key, err) + } + return req.URL, nil +} + func (s *S3Backend) Stat(ctx context.Context, path string) (*FileInfo, error) { start := time.Now() resp, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ diff --git a/cloud/aws/presign_test.go b/cloud/aws/presign_test.go new file mode 100644 index 0000000..eaead47 --- /dev/null +++ b/cloud/aws/presign_test.go @@ -0,0 +1,40 @@ +package aws + +import ( + "context" + "strings" + "testing" + "time" +) + +// TestPresignGetURL signs a flat key, which needs no HEAD and therefore no +// network: SigV4 presigning computes the signature locally. It checks the URL +// targets the right bucket/key and carries the expected query parameters. +func TestPresignGetURL(t *testing.T) { + be, err := NewBackend(Config{ + AccessKey: "AKIAEXAMPLE", + SecretKey: "secretexamplekey", + Bucket: "my-bucket", + Endpoint: "https://accountid.r2.cloudflarestorage.com", + }) + if err != nil { + t.Fatalf("NewBackend: %v", err) + } + + url, err := be.PresignGetURL(context.Background(), "photo.jpg", 15*time.Minute) + if err != nil { + t.Fatalf("PresignGetURL: %v", err) + } + + for _, want := range []string{ + "my-bucket", // path-style bucket segment (R2 auto path-style) + "photo.jpg", // the object key + "X-Amz-Signature", // SigV4 signature present + "X-Amz-Expires=900", // ttl in seconds + "X-Amz-Credential", // credential scope present + } { + if !strings.Contains(url, want) { + t.Errorf("presigned URL missing %q\n url = %s", want, url) + } + } +} diff --git a/go.work.sum b/go.work.sum index 5bba613..e9b12b3 100644 --- a/go.work.sum +++ b/go.work.sum @@ -478,13 +478,16 @@ github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lyft/protoc-gen-star/v2 v2.0.4-0.20230330145011-496ad1ac90a4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= @@ -493,6 +496,7 @@ github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5E github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= @@ -645,8 +649,10 @@ google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6yt google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/backend/metered.go b/internal/backend/metered.go index 61eb061..c63e1fe 100644 --- a/internal/backend/metered.go +++ b/internal/backend/metered.go @@ -2,10 +2,12 @@ package backend import ( "context" + "fmt" "io" "time" "github.com/Rhaqim/buckt/internal/domain" + "github.com/Rhaqim/buckt/pkg/buckterr" "github.com/Rhaqim/buckt/pkg/metrics" ) @@ -114,3 +116,16 @@ func (m *meteredBackend) DeleteFolder(ctx context.Context, prefix string) error m.record(metrics.OpDeleteFolder, 0, start, err) return err } + +// PresignGetURL forwards to the inner backend when it supports presigning, so +// the capability survives the metering wrapper. Returns ErrUnsupported when the +// inner backend can't presign (e.g. local). Not metered: GET presigning is a +// local signing operation (any existence check the inner backend does is counted +// by its own recorder). +func (m *meteredBackend) PresignGetURL(ctx context.Context, key string, ttl time.Duration) (string, error) { + p, ok := m.inner.(domain.PresignBackend) + if !ok { + return "", fmt.Errorf("backend %q does not support presigned URLs: %w", m.inner.Name(), buckterr.ErrUnsupported) + } + return p.PresignGetURL(ctx, key, ttl) +} diff --git a/internal/domain/backend.go b/internal/domain/backend.go index 707ef5e..e54ad57 100644 --- a/internal/domain/backend.go +++ b/internal/domain/backend.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "time" ) type FileBackend interface { @@ -37,6 +38,19 @@ type FileBackend interface { Move(ctx context.Context, oldPath, newPath string) error } +// PresignBackend is an OPTIONAL capability: a backend that can mint a +// time-limited URL granting direct access to an object, so reads don't have to +// stream through the application process. Cloud backends (S3/R2, and later +// Azure/GCS) implement it; the local filesystem backend does not. buckt detects +// it by type assertion (like MigratableBackend) and surfaces ErrUnsupported when +// the active backend can't presign. +type PresignBackend interface { + // PresignGetURL returns a URL that downloads the object at key directly from + // the backend, valid for ttl. The URL bypasses buckt's auth for its lifetime, + // so keep ttl short. + PresignGetURL(ctx context.Context, key string, ttl time.Duration) (string, error) +} + type MigratableBackend interface { FileBackend diff --git a/pkg/buckterr/buckterr.go b/pkg/buckterr/buckterr.go index 5f697ea..b3185c1 100644 --- a/pkg/buckterr/buckterr.go +++ b/pkg/buckterr/buckterr.go @@ -61,4 +61,9 @@ var ( // underlying reason remains inspectable. Map it to 4xx (e.g. 422). See // WithUploadScanner. ErrUploadRejected = errors.New("upload rejected") + + // ErrUnsupported is returned when an operation isn't supported by the active + // backend — e.g. a presigned URL on the local filesystem backend, or during + // migration mode. Map it to 501/400 as appropriate. + ErrUnsupported = errors.New("operation not supported by this backend") ) From 5aca033e84385cd4e9010f25c71c4db54894d2c5 Mon Sep 17 00:00:00 2001 From: Rhaqim Date: Mon, 31 Aug 2026 18:02:00 +0100 Subject: [PATCH 2/5] version update: readme --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2a479..2a4ef0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to buckt are documented here. This project follows [Semantic Versioning](https://semver.org/). -## [1.10.0] — unreleased +## [1.9.2] — unreleased A backward-compatible **minor** release adding presigned (direct-download) URLs. Additive; no API removals. From 24486bae092fa0731bd1009e497bcb2e1f99ebdc Mon Sep 17 00:00:00 2001 From: Rhaqim Date: Thu, 3 Sep 2026 18:24:28 +0100 Subject: [PATCH 3/5] presignput register/confirm --- CHANGELOG.md | 11 +++ README.md | 21 +++++ buckt.go | 47 ++++++++++ buckt_migration_test.go | 11 +++ buckt_presign_test.go | 89 +++++++++++++++++++ client/web/app/api.go | 60 +++++++++++++ client/web/domain/api.go | 5 ++ client/web/router/router.go | 2 + cloud/aws/backend.go | 15 ++++ cloud/aws/presign_test.go | 31 ++++++- example/migration/s3/main.go | 99 ++++++++++++++++++++++ internal/backend/metered.go | 9 ++ internal/database/schema/migrations.go | 16 ++++ internal/database/schema/schema_pg_test.go | 4 +- internal/database/schema/schema_test.go | 8 +- internal/domain/backend.go | 6 ++ internal/domain/repository.go | 2 + internal/domain/service.go | 5 ++ internal/mocks/file.go | 12 +++ internal/mocks/file_repo.go | 6 ++ internal/model/file.go | 6 ++ internal/repository/file.go | 26 +++++- internal/service/file.go | 78 +++++++++++++++++ 23 files changed, 558 insertions(+), 11 deletions(-) create mode 100644 example/migration/s3/main.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a4ef0d..be097af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,17 @@ Additive; no API removals. resolved first, so a URL for a migrated object doesn't 404. The web client adds `GET /presign/:file_id?ttl=15m` (501 when unsupported). Presigned URLs bypass buckt's auth for their lifetime — keep the TTL short. +- **Direct (presigned) uploads — register/confirm** (`Client.PresignUpload` / + `FinalizeUpload`, `PresignBackend.PresignPutURL`). `PresignUpload` reserves a + file (returning a **stable file ID** immediately, for your app's tracking) plus + a presigned PUT URL the client uploads to **directly**, bypassing your process + on the write path; `FinalizeUpload` confirms the object landed and makes the + file live, firing `file.uploaded` so handlers (e.g. derivative generation) run + lazily. The reserved file is `pending` (new column, schema migration v7) and + hidden from listings until finalized. Because buckt never sees the bytes on + this path, these uploads are **not** deduplicated, scanned, or content-hashed. + Web endpoints `POST /upload/presign` and `POST /upload/finalize` (form or + JSON). `example/migration/s3` is a copy-paste local→S3 migration template. ## [1.9.1] — unreleased diff --git a/README.md b/README.md index 8a8cde5..f838f57 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,24 @@ Presigning is a **cloud-backend capability**: it works with S3/R2 (`cloud/aws`) > A presigned URL grants access to whoever holds it — bypassing buckt's own auth — for the whole TTL, so keep the TTL short and don't log the URLs. The web client exposes `GET /presign/:file_id?ttl=15m` (returns 501 when the backend can't presign). +### Direct uploads (register / confirm) + +The reverse direction: let the client upload **straight to the bucket** so the bytes never pass through your server. It's a two-step handshake, and the **file ID is stable from step 1** — so your app can track it immediately. + +```go + // 1. Reserve — returns a stable file ID + a presigned PUT URL: + fileID, uploadURL, _ := client.PresignUpload(userID, parentID, "video.mp4", "video/mp4", 15*time.Minute) + + // 2. The client PUTs the bytes directly to uploadURL (browser → bucket). + + // 3. Confirm — makes the file live and fires file.uploaded (derivatives, etc.): + client.FinalizeUpload(fileID, sizeInBytes) +``` + +Until you finalize, the file is **pending** and hidden from listings. Trade-off: because buckt never sees the bytes on this path, these uploads are **not deduplicated, scanned, or content-hashed**, and image derivatives are generated lazily at finalize (via the upload event). Keep bytes-through-the-backend uploads (`UploadFile`) for anything that needs those; use direct uploads for large/opaque files. + +Web endpoints for the frontend: `POST /upload/presign` → `{file_id, upload_url}`, then `POST /upload/finalize` (both accept form or JSON). Cloud backends only — `ErrUnsupported`/501 otherwise. + --- ## Storage Backends @@ -832,6 +850,8 @@ GetFile(fileID string) (*FileModel, error) GetFileStream(fileID string) (*FileModel, io.ReadCloser, error) PresignedURL(fileID string, ttl time.Duration) (string, error) // direct-download URL (cloud backends) PresignedDerivativeURL(fileID, name string, ttl time.Duration) (string, error) // direct URL for a derivative +PresignUpload(userID, parentID, name, contentType string, ttl time.Duration) (fileID, uploadURL string, err error) // reserve a direct upload +FinalizeUpload(fileID string, size int64) (string, error) // confirm a direct upload landed ListFiles(folderID string) ([]FileModel, error) ListFilesMetadata(folderID string) ([]FileModel, error) MoveFile(fileID, newParentID string) error @@ -894,6 +914,7 @@ Branch on failures with `errors.Is` using the re-exported sentinels (also availa | [Azure Blob Storage](example/cloud/azure/main.go) | Azure setup | | [Full-featured UI](example/client/web/ui/main.go) | Metrics, dedup, derivatives, and upload events across `local`/`migrate`/`r2` modes | | [Headless migration](example/migration/headless/main.go) | Bulk `MigrateAll` + progress polling without the UI | +| [Local → S3 migration](example/migration/s3/main.go) | Copy-paste template for the local→S3 swap + cut-over | | [Expiring / temp files](example/expiry/headless/main.go) | `SetFileTTL` → `PurgeExpired` lifecycle | --- diff --git a/buckt.go b/buckt.go index 2b9e6d9..bcfb513 100644 --- a/buckt.go +++ b/buckt.go @@ -1200,6 +1200,53 @@ func (b *Client) PresignedDerivativeURLContext(ctx context.Context, file_id, nam return p.PresignGetURL(ctx, derivativeKey(file_id, name), ttl) } +/* Direct (presigned) uploads — register / confirm */ + +// PresignUpload reserves a file and returns its stable ID plus a presigned PUT +// URL the client uploads the bytes to **directly** (bypassing this process on +// the write path). The file ID is valid immediately for your app's tracking, +// but the file stays hidden from listings until you call FinalizeUpload once the +// client's upload finishes. +// +// Because buckt never sees the bytes on this path, the upload is NOT +// deduplicated or scanned, and no content hash is computed; image derivatives +// are generated lazily at FinalizeUpload (via the file.uploaded event). Needs a +// cloud backend — returns ErrUnsupported on local or during a migration. +func (b *Client) PresignUpload(user_id, parent_id, file_name, content_type string, ttl time.Duration) (fileID, uploadURL string, err error) { + return b.PresignUploadContext(context.Background(), user_id, parent_id, file_name, content_type, ttl) +} + +// PresignUploadContext is PresignUpload with an explicit context. +func (b *Client) PresignUploadContext(ctx context.Context, user_id, parent_id, file_name, content_type string, ttl time.Duration) (fileID, uploadURL string, err error) { + p, ok := b.backend.(domain.PresignBackend) + if !ok { + return "", "", fmt.Errorf("backend %q does not support presigned uploads: %w", b.backend.Name(), ErrUnsupported) + } + fileID, key, err := b.fileService.CreatePendingUpload(ctx, user_id, parent_id, file_name, content_type) + if err != nil { + return "", "", err + } + uploadURL, err = p.PresignPutURL(ctx, key, ttl) + if err != nil { + return "", "", err + } + return fileID, uploadURL, nil +} + +// FinalizeUpload confirms a presigned upload completed (the object is present in +// the backend), makes the file live (visible in listings, readable), records its +// size, and fires the file.uploaded event so handlers such as derivative +// generation run. Call it after the client's PUT to the URL from PresignUpload +// succeeds. Returns the file ID. +func (b *Client) FinalizeUpload(file_id string, size int64) (string, error) { + return b.FinalizeUploadContext(context.Background(), file_id, size) +} + +// FinalizeUploadContext is FinalizeUpload with an explicit context. +func (b *Client) FinalizeUploadContext(ctx context.Context, file_id string, size int64) (string, error) { + return b.fileService.FinalizeUpload(ctx, file_id, size) +} + /* Helper Methods */ func initializeCache(conf CacheConfig, bucktLog domain.BucktLogger) (domain.CacheManager, domain.LRUCache) { diff --git a/buckt_migration_test.go b/buckt_migration_test.go index 1e9aa28..8a46323 100644 --- a/buckt_migration_test.go +++ b/buckt_migration_test.go @@ -121,6 +121,17 @@ func (m *memBackend) count() int { return len(m.objs) } +// PresignGetURL / PresignPutURL make memBackend satisfy domain.PresignBackend so +// the presigned-upload flow can be tested. The "URL" just encodes the key, so a +// test can extract it and simulate the client's direct upload with a plain Put. +func (m *memBackend) PresignGetURL(_ context.Context, key string, _ time.Duration) (string, error) { + return "memget://" + key, nil +} + +func (m *memBackend) PresignPutURL(_ context.Context, key string, _ time.Duration) (string, error) { + return "memput://" + key, nil +} + func TestMigration_BulkMigrateExistingFiles(t *testing.T) { dir := t.TempDir() mediaDir := filepath.Join(dir, "media") diff --git a/buckt_presign_test.go b/buckt_presign_test.go index e8ad406..3d8f472 100644 --- a/buckt_presign_test.go +++ b/buckt_presign_test.go @@ -1,6 +1,10 @@ package buckt import ( + "context" + "database/sql" + "path/filepath" + "strings" "testing" "time" @@ -21,4 +25,89 @@ func TestPresignedURL_UnsupportedOnLocal(t *testing.T) { _, err = c.PresignedDerivativeURL(fileID, "thumbnail", 15*time.Minute) assert.ErrorIs(t, err, ErrUnsupported) + + _, _, err = c.PresignUpload("u1", "", "b.txt", "text/plain", 15*time.Minute) + assert.ErrorIs(t, err, ErrUnsupported, "local backend cannot presign uploads") +} + +// presignClient builds a Client whose backend is a presign-capable in-memory +// backend (memBackend), so the register/confirm flow can run without a real +// cloud store. +func presignClient(t *testing.T) (*Client, *memBackend) { + t.Helper() + dir := t.TempDir() + sqlDB, err := sql.Open("sqlite3", filepath.Join(dir, "b.db")) + require.NoError(t, err) + t.Cleanup(func() { _ = sqlDB.Close() }) + + target := newMemBackend() + c, err := New(Config{ + DB: DBConfig{Driver: SQLite, Database: sqlDB}, + MediaDir: filepath.Join(dir, "media"), + Log: LogConfig{Silence: true}, + Backend: BackendConfig{Source: target}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + return c, target +} + +// TestPresignUpload_RegisterConfirmFlow drives the full direct-upload lifecycle: +// reserve (file ID valid, but hidden from listings) → client uploads straight to +// storage → finalize (file becomes live and readable). +func TestPresignUpload_RegisterConfirmFlow(t *testing.T) { + c, target := presignClient(t) + const user = "u1" + ctx := context.Background() + + folderID, err := c.NewFolder(user, "", "Docs", "") + require.NoError(t, err) + + // A normal file so the folder has one visible entry to compare against. + normalID, err := c.UploadFile(user, folderID, "a.txt", "text/plain", []byte("a")) + require.NoError(t, err) + + // Reserve a presigned upload. + pendingID, url, err := c.PresignUpload(user, folderID, "b.pdf", "application/pdf", 15*time.Minute) + require.NoError(t, err) + require.NotEmpty(t, pendingID) + require.True(t, strings.HasPrefix(url, "memput://"), "got a presigned PUT URL") + + // Before finalize the reserved file is hidden from listings. + files, err := c.ListFiles(folderID) + require.NoError(t, err) + require.Len(t, files, 1) + assert.Equal(t, normalID, files[0].ID.String(), "pending upload is not listed") + + // Simulate the client PUTting the bytes directly to storage. + key := strings.TrimPrefix(url, "memput://") + body := []byte("PDF-BYTES") + require.NoError(t, target.Put(ctx, key, body)) + + // Finalize — the file becomes live. + gotID, err := c.FinalizeUpload(pendingID, int64(len(body))) + require.NoError(t, err) + assert.Equal(t, pendingID, gotID) + + // Now it's listed and readable, with the right size. + files, err = c.ListFiles(folderID) + require.NoError(t, err) + assert.Len(t, files, 2, "finalized file now appears in listings") + + f, err := c.GetFile(pendingID) + require.NoError(t, err) + assert.Equal(t, body, f.Data) + assert.False(t, f.Pending) + assert.Equal(t, int64(len(body)), f.Size) +} + +// TestFinalizeUpload_RejectsMissingObject verifies finalize fails if the client +// never actually uploaded the object (nothing at the reserved key). +func TestFinalizeUpload_RejectsMissingObject(t *testing.T) { + c, _ := presignClient(t) + pendingID, _, err := c.PresignUpload("u1", "", "never.pdf", "application/pdf", 15*time.Minute) + require.NoError(t, err) + + _, err = c.FinalizeUpload(pendingID, 10) + assert.ErrorIs(t, err, ErrNotFound, "finalize refuses a file whose object never landed") } diff --git a/client/web/app/api.go b/client/web/app/api.go index 02d9a7b..89de7d4 100644 --- a/client/web/app/api.go +++ b/client/web/app/api.go @@ -579,6 +579,66 @@ func (svc *APIService) SetExpiry(c *gin.Context) { c.JSON(200, response.Success("expiry set")) } +// PresignUpload reserves a file and returns its id plus a presigned PUT URL the +// client uploads bytes to directly (bypassing this server). Accepts form or JSON +// {parent_id?, file_name, content_type?, ttl?}. Call FinalizeUpload after the +// client's PUT succeeds. 501 when the backend can't presign. +func (svc *APIService) PresignUpload(c *gin.Context) { + userID := c.GetString("owner_id") + + var req struct { + ParentID string `form:"parent_id" json:"parent_id"` + FileName string `form:"file_name" json:"file_name" binding:"required"` + ContentType string `form:"content_type" json:"content_type"` + TTL string `form:"ttl" json:"ttl"` + } + if err := c.ShouldBind(&req); err != nil { + c.AbortWithStatusJSON(400, response.Error("file_name is required", err.Error())) + return + } + + ttl := 15 * time.Minute + if req.TTL != "" { + d, err := time.ParseDuration(req.TTL) + if err != nil { + c.AbortWithStatusJSON(400, response.Error("invalid ttl (use a Go duration like 15m)", err.Error())) + return + } + ttl = d + } + + fileID, url, err := svc.client.PresignUploadContext(c.Request.Context(), userID, req.ParentID, req.FileName, req.ContentType, ttl) + if err != nil { + if errors.Is(err, buckt.ErrUnsupported) { + c.AbortWithStatusJSON(501, response.WrapError("this backend cannot presign uploads", err)) + return + } + abort500(c, "failed to presign upload", err) + return + } + c.JSON(200, gin.H{"status": "success", "file_id": fileID, "upload_url": url}) +} + +// FinalizeUpload confirms a presigned upload landed and makes the file live. +// Accepts form or JSON {file_id, size?}. +func (svc *APIService) FinalizeUpload(c *gin.Context) { + var req struct { + FileID string `form:"file_id" json:"file_id" binding:"required"` + Size int64 `form:"size" json:"size"` + } + if err := c.ShouldBind(&req); err != nil { + c.AbortWithStatusJSON(400, response.Error("file_id is required", err.Error())) + return + } + + id, err := svc.client.FinalizeUploadContext(c.Request.Context(), req.FileID, req.Size) + if err != nil { + abort500(c, "failed to finalize upload", err) + return + } + c.JSON(200, gin.H{"status": "success", "file_id": id}) +} + // Presign returns a time-limited URL that downloads the file directly from the // storage backend (so reads bypass this process). The optional `ttl` query is a // Go duration (default 15m). Returns 501 when the backend can't presign (e.g. diff --git a/client/web/domain/api.go b/client/web/domain/api.go index 8e070c0..97387bb 100644 --- a/client/web/domain/api.go +++ b/client/web/domain/api.go @@ -21,6 +21,11 @@ type APIService interface { // Presign returns a time-limited direct-download URL for a file. Presign(c *gin.Context) + // PresignUpload reserves a file and returns a presigned PUT URL for direct upload. + PresignUpload(c *gin.Context) + // FinalizeUpload confirms a presigned upload landed and makes the file live. + FinalizeUpload(c *gin.Context) + // SetExpiry sets or clears a file's automatic-deletion time (ttl or absolute at). SetExpiry(c *gin.Context) // PurgeExpired permanently deletes every file whose expiry has passed. diff --git a/client/web/router/router.go b/client/web/router/router.go index 62eb053..d07e81e 100644 --- a/client/web/router/router.go +++ b/client/web/router/router.go @@ -122,6 +122,8 @@ func (r *Router) registerAPIRoutes() { r.Use(r.APIGuardMiddleware()) { r.POST("/upload", r.APIService.UploadFile) + r.POST("/upload/presign", r.APIService.PresignUpload) + r.POST("/upload/finalize", r.APIService.FinalizeUpload) r.GET("/download/:file_id", r.APIService.DownloadFile) r.GET("/presign/:file_id", r.APIService.Presign) r.DELETE("/delete/:file_id", r.APIService.DeleteFile) diff --git a/cloud/aws/backend.go b/cloud/aws/backend.go index 33181af..cb34c93 100644 --- a/cloud/aws/backend.go +++ b/cloud/aws/backend.go @@ -311,6 +311,21 @@ func (s *S3Backend) PresignGetURL(ctx context.Context, path string, ttl time.Dur return req.URL, nil } +// PresignPutURL returns a URL a client can HTTP PUT an object to, uploading +// directly to S3/R2 without the bytes passing through the application. Signed +// locally (no network). The key is used verbatim — the caller (buckt's upload +// reservation) supplies the exact object key. +func (s *S3Backend) PresignPutURL(ctx context.Context, key string, ttl time.Duration) (string, error) { + req, err := s3.NewPresignClient(s.client).PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucketName), + Key: aws.String(key), + }, s3.WithPresignExpires(ttl)) + if err != nil { + return "", fmt.Errorf("failed to presign PUT for %s: %w", key, err) + } + return req.URL, nil +} + func (s *S3Backend) Stat(ctx context.Context, path string) (*FileInfo, error) { start := time.Now() resp, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ diff --git a/cloud/aws/presign_test.go b/cloud/aws/presign_test.go index eaead47..d2bcdf7 100644 --- a/cloud/aws/presign_test.go +++ b/cloud/aws/presign_test.go @@ -34,7 +34,36 @@ func TestPresignGetURL(t *testing.T) { "X-Amz-Credential", // credential scope present } { if !strings.Contains(url, want) { - t.Errorf("presigned URL missing %q\n url = %s", want, url) + t.Errorf("presigned GET URL missing %q\n url = %s", want, url) + } + } +} + +// TestPresignPutURL checks the upload URL is a signed PUT for the given key. +func TestPresignPutURL(t *testing.T) { + be, err := NewBackend(Config{ + AccessKey: "AKIAEXAMPLE", + SecretKey: "secretexamplekey", + Bucket: "my-bucket", + Endpoint: "https://accountid.r2.cloudflarestorage.com", + }) + if err != nil { + t.Fatalf("NewBackend: %v", err) + } + + url, err := be.PresignPutURL(context.Background(), "uploads/report.pdf", 10*time.Minute) + if err != nil { + t.Fatalf("PresignPutURL: %v", err) + } + + for _, want := range []string{ + "my-bucket", + "uploads/report.pdf", + "X-Amz-Signature", + "X-Amz-Expires=600", + } { + if !strings.Contains(url, want) { + t.Errorf("presigned PUT URL missing %q\n url = %s", want, url) } } } diff --git a/example/migration/s3/main.go b/example/migration/s3/main.go new file mode 100644 index 0000000..fc7e386 --- /dev/null +++ b/example/migration/s3/main.go @@ -0,0 +1,99 @@ +// Package main is a copy-paste template for migrating an app from local +// filesystem storage to AWS S3 with zero downtime — the "Phase 1" swap. It's +// application-side wiring: buckt already does all the work. +// +// The lifecycle: +// 1. Run in migration mode (local is the source of truth, S3 the target). Every +// new upload dual-writes to both; reads fall back to S3. +// 2. MigrateAll bulk-copies the files that already exist on local disk to S3 +// (concurrent, resumable across restarts, retries transient failures). +// 3. Once MigrationStatus reports completion with zero MigrationFailures, cut +// over: redeploy with buckt.WithBackend(s3) alone (drop local). +// +// Real AWS S3 config differs from Cloudflare R2: set Region, and DON'T set an +// Endpoint (that's the R2/MinIO override). Configure from the environment: +// +// export AWS_REGION=us-east-1 AWS_S3_BUCKET=my-bucket \ +// AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... +// go run ./migration/s3 +package main + +import ( + "context" + "log" + "os" + "time" + + "github.com/Rhaqim/buckt" + "github.com/Rhaqim/buckt/cloud/aws" +) + +func main() { + s3, err := newS3Backend() + if err != nil { + log.Fatalf("s3 backend: %v", err) + } + + // Migration mode: local (the app's current storage) is the source of truth, + // S3 is the destination. Reuses ./db.sqlite + ./media, so files an earlier + // local-only run stored are picked up by MigrateAll below. + client, err := buckt.Default( + buckt.WithLog(buckt.LogConfig{Silence: true}), + buckt.WithMigration(buckt.MigrationConfig{ + From: buckt.LocalBackend(), + To: s3, + Concurrency: 16, // tune for throughput vs memory / S3 rate limits + }), + ) + if err != nil { + log.Fatalf("init: %v", err) + } + defer client.Close() + + ctx := context.Background() + log.Printf("backend: %s (dual-writing new uploads; bulk-copying existing files)", client.BackendName()) + + // Bulk-copy every pre-existing local file to S3. Safe to re-run after an + // interruption — already-copied files are skipped from persisted state. + if err := client.MigrateAll(ctx); err != nil { + log.Fatalf("MigrateAll: %v", err) + } + for { + done, total, _ := client.MigrationStatus(ctx) + log.Printf("migrated %d/%d", done, total) + if total > 0 && done >= total { + break + } + if total == 0 { + log.Println("no pre-existing files to copy") + break + } + time.Sleep(time.Second) + } + + if failed, _ := client.MigrationFailures(ctx); failed > 0 { + log.Printf("⚠️ %d file(s) failed to copy — fix the cause and re-run before cutting over", failed) + return + } + + log.Println("✅ migration complete. Cut over by redeploying with:") + log.Println(" buckt.Default(buckt.WithBackend(s3)) // S3 only, drop local") +} + +// newS3Backend builds an AWS S3 backend from the environment. Note: Region is +// required and there is NO Endpoint (unlike R2/MinIO). +func newS3Backend() (buckt.Backend, error) { + backend, err := aws.NewBackend(aws.Config{ + AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"), + SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), + Region: os.Getenv("AWS_REGION"), + Bucket: os.Getenv("AWS_S3_BUCKET"), + }) + if err != nil { + return nil, err + } + if err := backend.Ping(context.Background()); err != nil { + return nil, err + } + return backend, nil +} diff --git a/internal/backend/metered.go b/internal/backend/metered.go index c63e1fe..8140377 100644 --- a/internal/backend/metered.go +++ b/internal/backend/metered.go @@ -129,3 +129,12 @@ func (m *meteredBackend) PresignGetURL(ctx context.Context, key string, ttl time } return p.PresignGetURL(ctx, key, ttl) } + +// PresignPutURL forwards to the inner backend when it can presign (see PresignGetURL). +func (m *meteredBackend) PresignPutURL(ctx context.Context, key string, ttl time.Duration) (string, error) { + p, ok := m.inner.(domain.PresignBackend) + if !ok { + return "", fmt.Errorf("backend %q does not support presigned URLs: %w", m.inner.Name(), buckterr.ErrUnsupported) + } + return p.PresignPutURL(ctx, key, ttl) +} diff --git a/internal/database/schema/migrations.go b/internal/database/schema/migrations.go index c3da6c4..419008d 100644 --- a/internal/database/schema/migrations.go +++ b/internal/database/schema/migrations.go @@ -60,9 +60,25 @@ func migrations() []Migration { Name: "file-expires-at", Up: migrateFileExpiresAt, }, + { + Version: 7, + Name: "file-pending", + Up: migrateFilePending, + }, } } +// migrateFilePending adds the pending column used by presigned direct uploads +// (the metadata row exists before the bytes land). Additive and non-destructive: +// AutoMigrate only ADDs the column with a false default; existing rows become +// non-pending (correct — they already have their bytes). +func migrateFilePending(ctx context.Context, tx *gorm.DB, prefix string, d Dialect) error { + if err := tx.AutoMigrate(&model.FileModel{}); err != nil { + return fmt.Errorf("file pending column: %w", err) + } + return nil +} + // migrateFileExpiresAt adds the nullable, indexed expires_at column used by the // temp-file / expiry feature. Additive and non-destructive: AutoMigrate only // ADDs the column (existing rows get NULL = never expires) and its index; on a diff --git a/internal/database/schema/schema_pg_test.go b/internal/database/schema/schema_pg_test.go index d6e542f..fb98d3a 100644 --- a/internal/database/schema/schema_pg_test.go +++ b/internal/database/schema/schema_pg_test.go @@ -95,7 +95,7 @@ func TestApply_PreservesLegacyTrashOnV141Upgrade_Postgres(t *testing.T) { var versions []int require.NoError(t, gdb.Raw(`SELECT version FROM buckt_schema_migrations ORDER BY version`).Scan(&versions).Error) - assert.Equal(t, []int{1, 2, 3, 4, 5, 6}, versions) + assert.Equal(t, []int{1, 2, 3, 4, 5, 6, 7}, versions) assert.False(t, colExistsPG(t, gdb, "file_models", "deleted_at")) assert.False(t, colExistsPG(t, gdb, "folder_models", "deleted_at")) @@ -140,5 +140,5 @@ func TestApply_FreshDatabase_Postgres(t *testing.T) { var versions []int require.NoError(t, gdb.Raw(`SELECT version FROM buckt_schema_migrations ORDER BY version`).Scan(&versions).Error) - assert.Equal(t, []int{1, 2, 3, 4, 5, 6}, versions) + assert.Equal(t, []int{1, 2, 3, 4, 5, 6, 7}, versions) } diff --git a/internal/database/schema/schema_test.go b/internal/database/schema/schema_test.go index 43260af..9233644 100644 --- a/internal/database/schema/schema_test.go +++ b/internal/database/schema/schema_test.go @@ -117,7 +117,7 @@ func TestApply_PreservesLegacyTrashOnV141Upgrade(t *testing.T) { // Ledger recorded both migrations. var versions []int require.NoError(t, gdb.Raw(`SELECT version FROM buckt_schema_migrations ORDER BY version`).Scan(&versions).Error) - assert.Equal(t, []int{1, 2, 3, 4, 5, 6}, versions) + assert.Equal(t, []int{1, 2, 3, 4, 5, 6, 7}, versions) // deleted_at columns are gone from both tables. assert.False(t, colExists(t, gdb, "file_models", "deleted_at"), "file_models.deleted_at should be dropped") @@ -180,7 +180,7 @@ func TestApply_IsIdempotent(t *testing.T) { var versions []int require.NoError(t, gdb.Raw(`SELECT version FROM buckt_schema_migrations ORDER BY version`).Scan(&versions).Error) - assert.Equal(t, []int{1, 2, 3, 4, 5, 6}, versions) + assert.Equal(t, []int{1, 2, 3, 4, 5, 6, 7}, versions) } func TestApply_FreshDatabase(t *testing.T) { @@ -196,7 +196,7 @@ func TestApply_FreshDatabase(t *testing.T) { var versions []int require.NoError(t, gdb.Raw(`SELECT version FROM buckt_schema_migrations ORDER BY version`).Scan(&versions).Error) - assert.Equal(t, []int{1, 2, 3, 4, 5, 6}, versions) + assert.Equal(t, []int{1, 2, 3, 4, 5, 6, 7}, versions) } func TestApply_WithTablePrefix(t *testing.T) { @@ -212,7 +212,7 @@ func TestApply_WithTablePrefix(t *testing.T) { var versions []int require.NoError(t, gdb.Raw(`SELECT version FROM `+prefix+`buckt_schema_migrations ORDER BY version`).Scan(&versions).Error) - assert.Equal(t, []int{1, 2, 3, 4, 5, 6}, versions) + assert.Equal(t, []int{1, 2, 3, 4, 5, 6, 7}, versions) // No un-prefixed tables were created. assert.False(t, colExists(t, gdb, "folder_models", "id")) diff --git a/internal/domain/backend.go b/internal/domain/backend.go index e54ad57..e40224b 100644 --- a/internal/domain/backend.go +++ b/internal/domain/backend.go @@ -49,6 +49,12 @@ type PresignBackend interface { // the backend, valid for ttl. The URL bypasses buckt's auth for its lifetime, // so keep ttl short. PresignGetURL(ctx context.Context, key string, ttl time.Duration) (string, error) + + // PresignPutURL returns a URL that uploads (HTTP PUT) an object to key + // directly to the backend, valid for ttl — so a client can upload straight to + // storage without the bytes passing through the application. Used by the + // register/confirm upload flow (CreatePendingUpload / FinalizeUpload). + PresignPutURL(ctx context.Context, key string, ttl time.Duration) (string, error) } type MigratableBackend interface { diff --git a/internal/domain/repository.go b/internal/domain/repository.go index 9b07899..ef49458 100644 --- a/internal/domain/repository.go +++ b/internal/domain/repository.go @@ -41,4 +41,6 @@ type FileRepository interface { SetExpiry(ctx context.Context, id uuid.UUID, at *time.Time) error // FindExpired returns up to limit files whose expiry is at or before now. FindExpired(ctx context.Context, now time.Time, limit int) ([]*model.FileModel, error) + // FinalizeUpload clears a pending presigned-upload row and records its size. + FinalizeUpload(ctx context.Context, id uuid.UUID, size int64) (*model.FileModel, error) } diff --git a/internal/domain/service.go b/internal/domain/service.go index 7118a12..e49c40a 100644 --- a/internal/domain/service.go +++ b/internal/domain/service.go @@ -41,4 +41,9 @@ type FileService interface { SetExpiry(ctx context.Context, file_id string, at *time.Time) error // FindExpired returns up to limit files whose expiry is at or before now. FindExpired(ctx context.Context, now time.Time, limit int) ([]*model.FileModel, error) + // CreatePendingUpload reserves a file row for an out-of-band (presigned) + // upload, returning the stable file ID and the object key to upload to. + CreatePendingUpload(ctx context.Context, user_id, parent_id, file_name, content_type string) (fileID, objectKey string, err error) + // FinalizeUpload confirms a presigned upload landed and makes the file live. + FinalizeUpload(ctx context.Context, file_id string, size int64) (string, error) } diff --git a/internal/mocks/file.go b/internal/mocks/file.go index 3022d28..13f0188 100644 --- a/internal/mocks/file.go +++ b/internal/mocks/file.go @@ -108,6 +108,18 @@ func (m *FileService) FindExpired(ctx context.Context, now time.Time, limit int) return files, args.Error(1) } +// CreatePendingUpload implements domain.FileService. +func (m *FileService) CreatePendingUpload(ctx context.Context, user_id, parent_id, file_name, content_type string) (string, string, error) { + args := m.Called(user_id, parent_id, file_name, content_type) + return args.String(0), args.String(1), args.Error(2) +} + +// FinalizeUpload implements domain.FileService. +func (m *FileService) FinalizeUpload(ctx context.Context, file_id string, size int64) (string, error) { + args := m.Called(file_id, size) + return args.String(0), args.Error(1) +} + // GetMetadata implements domain.FileService. func (m *FileService) GetMetadata(ctx context.Context, file_id string) (map[string]string, error) { args := m.Called(file_id) diff --git a/internal/mocks/file_repo.go b/internal/mocks/file_repo.go index 8dc5826..89095f6 100644 --- a/internal/mocks/file_repo.go +++ b/internal/mocks/file_repo.go @@ -117,6 +117,12 @@ func (m *FileRepository) FindExpired(ctx context.Context, now time.Time, limit i return files, args.Error(1) } +func (m *FileRepository) FinalizeUpload(ctx context.Context, id uuid.UUID, size int64) (*model.FileModel, error) { + args := m.Called(id, size) + f, _ := args.Get(0).(*model.FileModel) + return f, args.Error(1) +} + func (m *FileRepository) ScrubFile(ctx context.Context, fileID uuid.UUID) error { args := m.Called(fileID) return args.Error(0) diff --git a/internal/model/file.go b/internal/model/file.go index 1d51473..926af44 100644 --- a/internal/model/file.go +++ b/internal/model/file.go @@ -32,6 +32,12 @@ type FileModel struct { // and emits a file.purged event. Nil means the file never expires. Indexed so // the expiry sweep is a cheap ranged query, not a full-table scan. ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"` + // Pending is true between a presigned-upload reservation (CreatePendingUpload) + // and its finalization (FinalizeUpload): the metadata row exists so the file + // ID is stable, but the bytes may not be in the backend yet. Pending files are + // hidden from listings and their bytes aren't fetched until finalized. Always + // false for normal (bytes-through-the-backend) uploads. + Pending bool `gorm:"not null;default:false" json:"pending,omitempty"` // DerivativesBytes is the total size of generated image derivatives for this // file (thumbnails etc.), which live on the backend but are not tracked as // files. Counted in Client.StorageBytes so storage totals stay accurate. diff --git a/internal/repository/file.go b/internal/repository/file.go index e80bc50..dc86562 100644 --- a/internal/repository/file.go +++ b/internal/repository/file.go @@ -35,19 +35,21 @@ func (f *FileRepository) GetFile(ctx context.Context, id uuid.UUID) (*model.File return &file, asNotFound(err, "file not found") } -// GetFiles implements domain.FileRepository. +// GetFiles implements domain.FileRepository. Pending files (a presigned upload +// that hasn't been finalized) are excluded — their bytes may not be stored yet. func (f *FileRepository) GetFiles(ctx context.Context, parent_id uuid.UUID) ([]*model.FileModel, error) { var files []*model.FileModel - err := f.db.DB.WithContext(ctx).Where("parent_id = ?", parent_id).Find(&files).Error + err := f.db.DB.WithContext(ctx).Where("parent_id = ? AND pending = ?", parent_id, false).Find(&files).Error return files, err } -// GetFilesPaginated implements domain.FileRepository. +// GetFilesPaginated implements domain.FileRepository. Pending files are excluded +// (see GetFiles). func (f *FileRepository) GetFilesPaginated(ctx context.Context, parent_id uuid.UUID, page model.Pagination) ([]*model.FileModel, error) { page.Validate() var files []*model.FileModel err := f.db.DB.WithContext(ctx). - Where("parent_id = ?", parent_id). + Where("parent_id = ? AND pending = ?", parent_id, false). Offset(page.Offset()). Limit(page.PageSize). Order("created_at DESC"). @@ -339,6 +341,22 @@ func (f *FileRepository) SetExpiry(ctx context.Context, id uuid.UUID, at *time.T return f.db.DB.WithContext(ctx).Model(&file).Select("ExpiresAt").Updates(file).Error } +// FinalizeUpload marks a pending presigned-upload row complete: it clears the +// pending flag and records the final size. Returns the (now-finalized) file so +// callers can emit an upload event. +func (f *FileRepository) FinalizeUpload(ctx context.Context, id uuid.UUID, size int64) (*model.FileModel, error) { + var file model.FileModel + if err := f.db.DB.WithContext(ctx).First(&file, id).Error; err != nil { + return nil, asNotFound(err, "file not found") + } + file.Pending = false + file.Size = size + if err := f.db.DB.WithContext(ctx).Model(&file).Select("Pending", "Size").Updates(file).Error; err != nil { + return nil, err + } + return &file, nil +} + // FindExpired returns up to limit files whose expires_at is set and at or before // now, ordered oldest-expiry first so the most-overdue files are purged first. func (f *FileRepository) FindExpired(ctx context.Context, now time.Time, limit int) ([]*model.FileModel, error) { diff --git a/internal/service/file.go b/internal/service/file.go index 04083e1..55592eb 100644 --- a/internal/service/file.go +++ b/internal/service/file.go @@ -741,6 +741,84 @@ func (f *FileService) FindExpired(ctx context.Context, now time.Time, limit int) return files, nil } +// CreatePendingUpload reserves a metadata row for a file whose bytes will be +// uploaded out of band (via a presigned URL) and returns its stable file ID and +// the object key the bytes must land at. The row is marked pending — hidden from +// listings — until FinalizeUpload is called. No dedup or scan runs (buckt never +// sees the bytes on this path); derivatives are generated lazily at finalize. +func (f *FileService) CreatePendingUpload(ctx context.Context, user_id, parent_id, file_name, content_type string) (string, string, error) { + if err := utils.ValidateFileName(file_name); err != nil { + return "", "", fmt.Errorf("invalid file name: %w", buckterr.ErrInvalidName) + } + if content_type == "" { + content_type = "application/octet-stream" + } + + parentFolder, err := f.folderService.GetFolder(ctx, user_id, parent_id) + if err != nil { + parentFolder, err = f.folderService.GetRootFolder(ctx, user_id) + if err != nil { + return "", "", err + } + } + + path := filepath.Join(parentFolder.Path, file_name) + if f.flatNameSpaces { + ext := filepath.Ext(file_name) + path = uuid.New().String() + ext + } + + file := &model.FileModel{ + UserID: user_id, + ParentID: parentFolder.ID, + Name: file_name, + Path: path, + ContentType: content_type, + Pending: true, + } + if err := f.repo.Create(ctx, file); err != nil { + return "", "", f.logger.WrapError("failed to reserve upload", err) + } + return file.ID.String(), file.Path, nil +} + +// FinalizeUpload completes a presigned upload: it confirms the object landed in +// the backend, clears the pending flag, records the size, and emits a +// file.uploaded event (so existing handlers — e.g. derivative generation — run). +func (f *FileService) FinalizeUpload(ctx context.Context, file_id string, size int64) (string, error) { + fileID, err := uuid.Parse(file_id) + if err != nil { + return "", fmt.Errorf("invalid id: %w", buckterr.ErrInvalidID) + } + + // Reserved row — carries the object key the client uploaded to. + file, err := f.repo.GetFile(ctx, fileID) + if err != nil { + return "", f.logger.WrapError("failed to get file metadata", err) + } + + // Don't expose the file until the object is actually present. + exists, err := f.fileBackend.Exists(ctx, file.Path) + if err != nil { + return "", f.logger.WrapError("failed to verify uploaded object", err) + } + if !exists { + return "", fmt.Errorf("uploaded object not found for %s: %w", file_id, buckterr.ErrNotFound) + } + + finalized, err := f.repo.FinalizeUpload(ctx, fileID, size) + if err != nil { + return "", f.logger.WrapError("failed to finalize upload", err) + } + + if f.cache != nil { + _ = f.cache.DeleteBucktValue(ctx, file_id) + } + f.emitFile(ctx, events.FileUploaded, finalized) + + return finalized.ID.String(), nil +} + // GetMetadata returns the metadata stored on a file (nil when none is set). func (f *FileService) GetMetadata(ctx context.Context, file_id string) (map[string]string, error) { fileID, err := uuid.Parse(file_id) From 7a2321500ccb14fc64394de1223a357f3fef1307 Mon Sep 17 00:00:00 2001 From: Rhaqim Date: Thu, 3 Sep 2026 18:31:04 +0100 Subject: [PATCH 4/5] presign docs --- README.md | 2 + docs/s3-migration-and-presigned-urls.md | 353 ++++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 docs/s3-migration-and-presigned-urls.md diff --git a/README.md b/README.md index f838f57..08a2cd0 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,8 @@ Or with options: Hand clients a time-limited URL that downloads a file **directly from the storage backend**, so reads don't stream through your process — the standard, CDN-friendly way to serve media (images, video, downloads). +> **Integrating an app?** See the step-by-step [S3 migration + presigned URLs guide](docs/s3-migration-and-presigned-urls.md) — local→S3 cut-over, presigned downloads, and direct uploads, with Go backend and Astro/Svelte frontend snippets. + ```go // 15-minute direct-download link for the file's bytes: url, err := client.PresignedURL(fileID, 15*time.Minute) diff --git a/docs/s3-migration-and-presigned-urls.md b/docs/s3-migration-and-presigned-urls.md new file mode 100644 index 0000000..18081c4 --- /dev/null +++ b/docs/s3-migration-and-presigned-urls.md @@ -0,0 +1,353 @@ +# Migrating to S3 + Presigned URLs — Integration Guide + +A guide for an application that already uses **buckt** with **local filesystem** +storage and wants to: + +1. **Move storage to AWS S3** with zero downtime, and +2. **Serve and (optionally) receive files directly to/from S3** using presigned + URLs, so bytes don't have to pass through the application server. + +It's written for a **Go backend** (where buckt lives) talking to an +**Astro/Svelte frontend**. The current shape is assumed to be: + +``` +browser ──upload──▶ Go backend ──▶ buckt ──▶ storage (local today) +browser ◀─download── Go backend ◀── buckt ◀── storage +``` + +Nothing here changes buckt's **file ID** — it stays the source of truth your app +tracks files by, before and after every change below. + +--- + +## Contents + +- [Part 1 — Migrate local → S3](#part-1--migrate-local--s3) +- [Part 2 — Presigned downloads (serve direct from S3)](#part-2--presigned-downloads-serve-direct-from-s3) +- [Part 3 — Direct uploads (register / confirm)](#part-3--direct-uploads-register--confirm) +- [Error reference](#error-reference) +- [Recommended rollout order](#recommended-rollout-order) + +--- + +## Part 1 — Migrate local → S3 + +This is **application-side config only** — buckt already does the work. Your +upload/download flow is unchanged during and after the migration, so every buckt +feature (hashing, dedup, image derivatives, upload scanning, metadata) keeps +working exactly as today. + +### 1.1 Build the S3 backend + +Real AWS S3 differs from Cloudflare R2 config: **`Region` is required** and there +is **no `Endpoint`** (that field is only for R2/MinIO-style S3-compatible stores). + +```go +import ( + "context" + + "github.com/Rhaqim/buckt" + "github.com/Rhaqim/buckt/cloud/aws" +) + +func newS3() (buckt.Backend, error) { + s3, err := aws.NewBackend(aws.Config{ + AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"), + SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), + Region: os.Getenv("AWS_REGION"), // e.g. "us-east-1" — REQUIRED + Bucket: os.Getenv("AWS_S3_BUCKET"), + // Endpoint: "" ← leave empty for real AWS S3 + }) + if err != nil { + return nil, err + } + // Fail fast on bad credentials / bucket, instead of on the first upload. + if err := s3.Ping(context.Background()); err != nil { + return nil, err + } + return s3, nil +} +``` + +### 1.2 Switch buckt into migration mode (dual-write) + +Instead of `buckt.WithBackend(local)`, wrap both backends. Every **new** upload is +now written to **both** local and S3; reads fall back to S3 if local is missing a +file. + +```go +s3, err := newS3() +// ... + +client, err := buckt.Default( + // ...your existing options (WithDB, WithImageDerivatives, WithEventHandler, ...) + buckt.WithMigration(buckt.MigrationConfig{ + From: buckt.LocalBackend(), // current source of truth + To: s3, // destination + Concurrency: 16, // parallel copies for the bulk pass + }), +) +``` + +Deploy this. You're now safely dual-writing with **no downtime**. + +### 1.3 Bulk-copy the files that already exist on local disk + +Dual-write only mirrors *new* activity. Copy the backlog once: + +```go +if err := client.MigrateAll(ctx); err != nil { + log.Fatal(err) +} +for { + done, total, _ := client.MigrationStatus(ctx) + log.Printf("migrated %d/%d", done, total) + if total > 0 && done >= total { + break + } + time.Sleep(time.Second) +} +if failed, _ := client.MigrationFailures(ctx); failed > 0 { + log.Printf("%d file(s) failed — check logs and re-run MigrateAll before cutting over", failed) +} +``` + +`MigrateAll` is **concurrent, resumable across restarts, and retries transient +failures** — safe to re-run. A runnable template is in +[`example/migration/s3`](../example/migration/s3/main.go). + +### 1.4 Cut over + +Once `MigrationStatus` reports `done == total` and `MigrationFailures` is `0`, +redeploy with S3 as the only backend: + +```go +client, err := buckt.Default( + // ...same options... + buckt.WithBackend(s3), // S3 only; local no longer written or read +) +``` + +Keep the local copies around for a while as a cheap backup if you like. Uploading +to S3 is **free ingress** (AWS doesn't charge to bring data in), so the migration +itself costs nothing in transfer — only per-request (Class A) charges. + +> **Note on presigning during migration:** the presigned-URL features in Parts 2 +> and 3 require a **single cloud backend**. While the client is in migration mode +> they return `ErrUnsupported`. So finish the cut-over (1.4) before relying on +> presigned URLs. + +--- + +## Part 2 — Presigned downloads (serve direct from S3) + +**This is the biggest cost win on S3.** S3 charges **egress** (data leaving to the +internet). Today, serving a file means S3 → your server → browser, so you pay +egress *and* your server does the work. A presigned GET URL makes it **S3 → +browser directly**: one hop, and your server is out of the byte path. + +Minting the URL still goes through your backend (it needs your S3 credentials); +only the **download** bypasses it. + +### 2.1 Backend + +Two Client methods (each has a `...Context` variant): + +```go +// Direct-download URL for the file's bytes, valid for the TTL: +url, err := client.PresignedURL(fileID, 15*time.Minute) + +// Direct URL for a generated image derivative (thumbnail, medium, ...): +thumbURL, err := client.PresignedDerivativeURL(fileID, "thumbnail", 15*time.Minute) + +if errors.Is(err, buckt.ErrUnsupported) { + // Local backend or mid-migration — fall back to streaming (GetFileStream / /serve). +} +``` + +If you use the bundled web client, there's also an HTTP endpoint: + +``` +GET /presign/:file_id?ttl=15m → { "status": "success", "url": "https://.s3..." } +``` + +(Returns `501` when the backend can't presign.) It sits behind the same API guard +as your other routes, so your existing auth applies. + +### 2.2 Frontend (Astro/Svelte) + +Swap image `src` / download links from the streaming route to a presigned URL. +Because URLs **expire**, mint them at render/request time — don't cache them +long-term. + +```ts +// Fetch a fresh presigned URL for a file id. +async function presignedURL(fileId: string, ttl = "15m"): Promise { + const res = await fetch(`/presign/${fileId}?ttl=${ttl}`, { + headers: { Authorization: `Bearer ${token}` }, // your app's auth + }); + if (!res.ok) throw new Error(`presign failed: ${res.status}`); + const { url } = await res.json(); + return url; +} +``` + +```svelte + + +{#if src} + +{/if} +``` + +**Tips** + +- Keep TTLs short (minutes). A presigned URL grants access to anyone holding it, + bypassing your auth, for its whole lifetime — so don't log them. +- For long-lived pages/galleries, re-mint on load rather than persisting URLs. +- For thumbnails, presign the **derivative** (`/serve/:id/derivative/:name` today + streams; back a handler with `PresignedDerivativeURL` to serve those direct too). + +--- + +## Part 3 — Direct uploads (register / confirm) + +The reverse direction: let the browser upload **straight to S3**, so large files +never pass through your server. This is a **two-step handshake**, and the buckt +**file ID is returned in step 1**, so your app can start tracking the file +immediately. + +> **Read this trade-off first.** On the direct path buckt **never sees the +> bytes**, so these uploads are **NOT deduplicated, NOT scanned, and NOT +> content-hashed**, and image derivatives are generated **lazily** at finalize. +> Also, on S3 uploads are **free ingress** — so this saves your **server's** +> bandwidth/CPU, not S3 fees. Use it for **large / opaque files** (video, +> archives, big PDFs). Keep the normal `UploadFile` flow (bytes through the +> backend) for anything that needs dedup/scan/derivatives inline (e.g. user +> images). + +### 3.1 The flow + +``` +1. browser ──"I want to upload X"──▶ backend ──▶ buckt.PresignUpload + returns { fileID, uploadURL } +2. browser ──PUT bytes──▶ S3 (directly, using uploadURL) +3. browser ──"done, fileID, size"──▶ backend ──▶ buckt.FinalizeUpload +``` + +Between steps 1 and 3 the file is **pending**: the row (and ID) exist, but the +file is hidden from listings and not readable until finalized. + +### 3.2 Backend + +```go +// Step 1 — reserve. Returns a stable file ID + a presigned PUT URL. +fileID, uploadURL, err := client.PresignUpload(userID, parentID, "video.mp4", "video/mp4", 15*time.Minute) +if errors.Is(err, buckt.ErrUnsupported) { + // Local / migration — fall back to the normal upload endpoint. +} + +// Step 3 — confirm (after the browser's PUT succeeds). Makes the file live and +// fires file.uploaded (so your derivative handler runs, reading bytes from S3). +_, err = client.FinalizeUpload(fileID, sizeInBytes) +``` + +Web endpoints (accept **form or JSON**), behind the API guard: + +``` +POST /upload/presign body: { parent_id?, file_name, content_type?, ttl? } + → { status, file_id, upload_url } + +POST /upload/finalize body: { file_id, size? } + → { status, file_id } +``` + +### 3.3 Frontend (Astro/Svelte) + +```ts +async function directUpload(file: File, parentId: string): Promise { + // 1. Reserve. + const reserve = await fetch("/upload/presign", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + parent_id: parentId, + file_name: file.name, + content_type: file.type, + ttl: "15m", + }), + }); + if (!reserve.ok) throw new Error(`reserve failed: ${reserve.status}`); + const { file_id, upload_url } = await reserve.json(); + + // 2. Upload the bytes straight to S3. Set Content-Type so the stored object + // has the right type for later presigned downloads (otherwise S3 stores it + // as binary and browsers download instead of rendering). + const put = await fetch(upload_url, { + method: "PUT", + headers: { "Content-Type": file.type }, + body: file, + }); + if (!put.ok) throw new Error(`upload failed: ${put.status}`); + + // 3. Confirm. + const finalize = await fetch("/upload/finalize", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ file_id, size: file.size }), + }); + if (!finalize.ok) throw new Error(`finalize failed: ${finalize.status}`); + + return file_id; // your app can now track / reference this file +} +``` + +**Important frontend detail:** send the `Content-Type` header on the PUT (step 2). +It isn't part of the presigned signature (so it's optional), but S3 stores +whatever you send — and that stored type is what a later presigned **download** +serves. Set it to the real MIME type so images/PDFs render inline instead of +downloading. + +### 3.4 Cleaning up abandoned reservations + +If a client reserves (step 1) but never finalizes (step 3), you're left with a +hidden pending row and possibly an orphan S3 object. Two easy guards: + +- Set an **S3 lifecycle rule** to expire incomplete/old objects under your prefix. +- Periodically delete stale pending files from your side (they're hidden from + listings but you hold the IDs you handed out). + +--- + +## Error reference + +Branch on these with `errors.Is`; they're re-exported from the root `buckt` +package. Suggested HTTP mappings are what the bundled web client uses. + +| Error | Meaning | HTTP | +|---|---|---| +| `buckt.ErrUnsupported` | Backend can't do this (presign on local, or mid-migration) | 501 | +| `buckt.ErrNotFound` | File/derivative/object doesn't exist (e.g. finalize before the PUT landed) | 404 | +| `buckt.ErrUploadRejected` | An upload scanner rejected the file (normal upload path) | 422 | +| `buckt.ErrFileTooLarge` | Upload exceeds `WithMaxFileSize` | 413 | +| `buckt.ErrBackendUnavailable` | Backend unreachable / feature not enabled | 503 | + +--- + +## Recommended rollout order + +1. **Migrate to S3** (Part 1) with the current upload/download flow. Lowest risk; + all features intact. Verify `MigrationFailures == 0`, then cut over. +2. **Adopt presigned downloads** (Part 2). This is the real S3 cost saver (egress) + and offloads your server on the high-volume read side. Frontend change only — + buckt already supports it. +3. **Adopt direct uploads selectively** (Part 3) — only for large/opaque files + where server upload load actually hurts. Keep the normal `UploadFile` flow for + images and anything needing dedup/scan/derivatives inline. + +Do them in that order and each step is independently shippable and reversible. From 2bc5fe20acc1be5773cdd1499aa078fe88460043 Mon Sep 17 00:00:00 2001 From: Rhaqim Date: Thu, 3 Sep 2026 18:35:42 +0100 Subject: [PATCH 5/5] deps update --- cloud/aws/go.mod | 14 +++++++------- cloud/aws/go.sum | 14 ++++++++++++++ cloud/gcp/go.mod | 16 ++++++++-------- cloud/gcp/go.sum | 36 ++++++++++++++++++------------------ example/go.mod | 40 ++++++++++++++++++++-------------------- example/go.sum | 30 ++++++++++++++++++++++++++++++ go.work.sum | 1 + 7 files changed, 98 insertions(+), 53 deletions(-) diff --git a/cloud/aws/go.mod b/cloud/aws/go.mod index dd37fd3..69093d5 100644 --- a/cloud/aws/go.mod +++ b/cloud/aws/go.mod @@ -4,9 +4,9 @@ go 1.26.0 require ( github.com/aws/aws-sdk-go-v2 v1.45.1 - github.com/aws/aws-sdk-go-v2/config v1.33.1 - github.com/aws/aws-sdk-go-v2/credentials v1.20.1 - github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 + github.com/aws/aws-sdk-go-v2/config v1.33.2 + github.com/aws/aws-sdk-go-v2/credentials v1.20.2 + github.com/aws/aws-sdk-go-v2/service/s3 v1.110.0 github.com/aws/smithy-go v1.28.1 github.com/cenkalti/backoff/v4 v4.3.0 ) @@ -21,8 +21,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.36.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.48.0 // indirect ) diff --git a/cloud/aws/go.sum b/cloud/aws/go.sum index dbc2bdf..f8d8acf 100644 --- a/cloud/aws/go.sum +++ b/cloud/aws/go.sum @@ -4,8 +4,12 @@ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2Vr github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw= github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A= +github.com/aws/aws-sdk-go-v2/config v1.33.2 h1:Pj4+nF2kc4Z+1BJysVPnX9d5dMN7IYFXR4UJaWK2IpA= +github.com/aws/aws-sdk-go-v2/config v1.33.2/go.mod h1:Igw+HTwbR2tsTU/ydifAS9EHAFJ2s/FCgkwQWFnAdE4= github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ= github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM= +github.com/aws/aws-sdk-go-v2/credentials v1.20.2 h1:VQjZODPNfdikCX2ZZrltw4zNLkcwjyUFDUl2vT9yTwg= +github.com/aws/aws-sdk-go-v2/credentials v1.20.2/go.mod h1:OmeHCn28vZylsBvalLDf7t8fuJ2rHYQprJs+7WuxniI= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec= @@ -24,14 +28,24 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 h1:ZMbtPZZQRca+3+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1/go.mod h1:YAGWQdCYlVCoqrzvfv3RLxO6zKwti7gsAULOGWPLYv4= github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 h1:kVpzaDBzOdRtOftmiSpTdQbWVqRg0kONLXijktiwXnk= github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.110.0 h1:He8vaTTqAAJrux/KdpjFXNWueLJZyKqE49QEXoqAu4I= +github.com/aws/aws-sdk-go-v2/service/s3 v1.110.0/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk= github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE= github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI= +github.com/aws/aws-sdk-go-v2/service/signin v1.8.0 h1:bSvKIoLuRGFqGwASgeCQncCJDi9YKKBDEmCEZzOX1uU= +github.com/aws/aws-sdk-go-v2/service/signin v1.8.0/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI= github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM= github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8= +github.com/aws/aws-sdk-go-v2/service/sso v1.36.0 h1:iivsh357VnfIc18IFWSuoyQEluf8frfWf4cL2Y0JUQw= +github.com/aws/aws-sdk-go-v2/service/sso v1.36.0/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0 h1:wVxM3QzSKIK8tSN6OGgezp9OK91lCLH2zhmRInN9rFM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c= github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0= github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk= +github.com/aws/aws-sdk-go-v2/service/sts v1.48.0 h1:RzZVCzYM19vhJCT5s6vO2wN8ie770Li/TmbAZ9B6N7E= +github.com/aws/aws-sdk-go-v2/service/sts v1.48.0/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk= github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= diff --git a/cloud/gcp/go.mod b/cloud/gcp/go.mod index c802cd3..c573d36 100644 --- a/cloud/gcp/go.mod +++ b/cloud/gcp/go.mod @@ -4,7 +4,7 @@ go 1.26.0 require ( cloud.google.com/go/storage v1.66.0 - google.golang.org/api v0.295.0 + google.golang.org/api v0.297.0 ) require ( @@ -15,9 +15,9 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.13.0 // indirect cloud.google.com/go/monitoring v1.30.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.37.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.61.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.61.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.39.0 // indirect @@ -41,16 +41,16 @@ require ( go.opentelemetry.io/otel/sdk v1.46.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect - golang.org/x/crypto v0.55.0 // indirect + golang.org/x/crypto v0.56.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect + google.golang.org/genproto v0.0.0-20260831171406-18b4a7587f8a // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/cloud/gcp/go.sum b/cloud/gcp/go.sum index 57cf84f..4fc7fc4 100644 --- a/cloud/gcp/go.sum +++ b/cloud/gcp/go.sum @@ -20,14 +20,14 @@ cloud.google.com/go/storage v1.66.0 h1:HwYx7m9Md/rzphAFshUeAWS3hNFsJQTgFrAu4RIRw cloud.google.com/go/storage v1.66.0/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 h1:3SdxXLkgAfiHRWcGTq6fneq9jgoJzneiY0yPQnjoT2E= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0/go.mod h1:1iIdl0k+ppn9wT0wzR9H7HkSvIui/4qgtnKW10cQtds= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 h1:HldzheTs05E3ybqSitI/wHaof6+XERRudgZLjYbs3eE= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0/go.mod h1:evkqaSczW9g2BQm1veCtgNhJ4wCCsRrOsSgNIn9LHQk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0 h1:Fx8NtDCmKH4ML2hUkPz4Dq250903vRDojMjVCDKwQuc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 h1:Oblia1QXBJlM/wOY9ARRUtsXdDYiMCzk3eCMikqoLbI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0/go.mod h1:SRAbhyZ4R4FagHMM9VtRgSY/lheRoht2fKelZXQUenk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.37.0 h1:edOuLWehuDlQ68roBF900Y09jvBoJP5W5d/wusz9nWY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.37.0/go.mod h1:iIyrdhveU5Ow84ipRlxtjCOQnIM0DiqSMxuxm50xwZY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.61.0 h1:BeC1Bub7Ttk33TF8v3ZeQ2L8acvz4ZLqtQuLyJAZquc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.61.0/go.mod h1:UgG/Xn9+NXb1/ueSczIFxyuya3hs3srFDiZG2JrpJwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.61.0 h1:eSgSuKdcpY7RTN1oXSzC6S39rPBImn03ubLMsWK4RRg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.61.0/go.mod h1:ifhe5teRNcbxg9p353DhOkOgczzL/IBLYFqT1cm1zpM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.61.0 h1:KhpL26/GviYITHMCEGGqT/l50siFaBCBBKrjv6Z0G/k= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.61.0/go.mod h1:sg6Wqbl0BKKZ5zKnQj2q0qic/3ziF894SCx1LpbX37Y= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= @@ -93,8 +93,8 @@ go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu6 go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -109,14 +109,14 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.295.0 h1:SSqFeEVjnK5SKo6t7D0E0M7EfX8SP7K0+OJd2Ly5FzU= -google.golang.org/api v0.295.0/go.mod h1:02qB8+Ox1ZFzcaKFMguy1nQLJmSIyvV6Ff4txJEXtl4= -google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 h1:jPP56YzdY899KJ5W7efXHt/CkjlVfAaoFOwdi/IEAFA= -google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5/go.mod h1:gutZdP0DwAHp4vu5WaXgEK7tjsJ77ZEqzlOFWGZGziE= -google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU= -google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/api v0.297.0 h1:WktxTsnnx0yZNnsR6j0q6hR21RnnK81FHTOPy/ux4OE= +google.golang.org/api v0.297.0/go.mod h1:S4m8x0M6OkQpkOzGk1y9JG2sm4fFQrMh6dxzjCTszhE= +google.golang.org/genproto v0.0.0-20260831171406-18b4a7587f8a h1:d5Vqs7VNOkWqGjSrJDOsHsLOm9Z0lAn+xHX7TCEcFmk= +google.golang.org/genproto v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:fzLclyAUFitqvij38hIBk5yYbwpWhLTxTRz6cpnfyvc= +google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs= +google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= diff --git a/example/go.mod b/example/go.mod index 68c15e1..ca0d93b 100644 --- a/example/go.mod +++ b/example/go.mod @@ -3,11 +3,11 @@ module example go 1.26.0 require ( - github.com/Rhaqim/buckt v1.9.0 - github.com/Rhaqim/buckt/client/web v0.0.0-20260811222453-a0c76c88650c - github.com/Rhaqim/buckt/cloud/aws v0.0.0-20260811222453-a0c76c88650c - github.com/Rhaqim/buckt/cloud/azure v0.0.0-20260811222453-a0c76c88650c - github.com/Rhaqim/buckt/cloud/gcp v0.0.0-20260811222453-a0c76c88650c + github.com/Rhaqim/buckt v1.9.1 + github.com/Rhaqim/buckt/client/web v0.0.0-20260831153104-cc4291ff0dd0 + github.com/Rhaqim/buckt/cloud/aws v0.0.0-20260831153104-cc4291ff0dd0 + github.com/Rhaqim/buckt/cloud/azure v0.0.0-20260831153104-cc4291ff0dd0 + github.com/Rhaqim/buckt/cloud/gcp v0.0.0-20260831153104-cc4291ff0dd0 ) require ( @@ -22,13 +22,13 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.37.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.61.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.61.0 // indirect github.com/aws/aws-sdk-go-v2 v1.45.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect - github.com/aws/aws-sdk-go-v2/config v1.33.1 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.20.1 // indirect + github.com/aws/aws-sdk-go-v2/config v1.33.2 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.2 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect @@ -37,11 +37,11 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.110.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.36.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.48.0 // indirect github.com/aws/smithy-go v1.28.1 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.3 // indirect @@ -106,17 +106,17 @@ require ( go.opentelemetry.io/otel/trace v1.46.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.30.0 // indirect - golang.org/x/crypto v0.55.0 // indirect + golang.org/x/crypto v0.56.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.295.0 // indirect - google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect + google.golang.org/api v0.297.0 // indirect + google.golang.org/genproto v0.0.0-20260831171406-18b4a7587f8a // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.12 // indirect gorm.io/driver/postgres v1.6.2 // indirect diff --git a/example/go.sum b/example/go.sum index 487e2df..2154959 100644 --- a/example/go.sum +++ b/example/go.sum @@ -34,20 +34,30 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMs github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 h1:3SdxXLkgAfiHRWcGTq6fneq9jgoJzneiY0yPQnjoT2E= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0/go.mod h1:1iIdl0k+ppn9wT0wzR9H7HkSvIui/4qgtnKW10cQtds= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.37.0 h1:edOuLWehuDlQ68roBF900Y09jvBoJP5W5d/wusz9nWY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.37.0/go.mod h1:iIyrdhveU5Ow84ipRlxtjCOQnIM0DiqSMxuxm50xwZY= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 h1:HldzheTs05E3ybqSitI/wHaof6+XERRudgZLjYbs3eE= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0/go.mod h1:evkqaSczW9g2BQm1veCtgNhJ4wCCsRrOsSgNIn9LHQk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.61.0 h1:BeC1Bub7Ttk33TF8v3ZeQ2L8acvz4ZLqtQuLyJAZquc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.61.0/go.mod h1:UgG/Xn9+NXb1/ueSczIFxyuya3hs3srFDiZG2JrpJwY= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0 h1:Fx8NtDCmKH4ML2hUkPz4Dq250903vRDojMjVCDKwQuc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 h1:Oblia1QXBJlM/wOY9ARRUtsXdDYiMCzk3eCMikqoLbI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0/go.mod h1:SRAbhyZ4R4FagHMM9VtRgSY/lheRoht2fKelZXQUenk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.61.0 h1:KhpL26/GviYITHMCEGGqT/l50siFaBCBBKrjv6Z0G/k= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.61.0/go.mod h1:sg6Wqbl0BKKZ5zKnQj2q0qic/3ziF894SCx1LpbX37Y= github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks= github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw= github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A= +github.com/aws/aws-sdk-go-v2/config v1.33.2 h1:Pj4+nF2kc4Z+1BJysVPnX9d5dMN7IYFXR4UJaWK2IpA= +github.com/aws/aws-sdk-go-v2/config v1.33.2/go.mod h1:Igw+HTwbR2tsTU/ydifAS9EHAFJ2s/FCgkwQWFnAdE4= github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ= github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM= +github.com/aws/aws-sdk-go-v2/credentials v1.20.2 h1:VQjZODPNfdikCX2ZZrltw4zNLkcwjyUFDUl2vT9yTwg= +github.com/aws/aws-sdk-go-v2/credentials v1.20.2/go.mod h1:OmeHCn28vZylsBvalLDf7t8fuJ2rHYQprJs+7WuxniI= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec= @@ -66,14 +76,24 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 h1:ZMbtPZZQRca+3+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1/go.mod h1:YAGWQdCYlVCoqrzvfv3RLxO6zKwti7gsAULOGWPLYv4= github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 h1:kVpzaDBzOdRtOftmiSpTdQbWVqRg0kONLXijktiwXnk= github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.110.0 h1:He8vaTTqAAJrux/KdpjFXNWueLJZyKqE49QEXoqAu4I= +github.com/aws/aws-sdk-go-v2/service/s3 v1.110.0/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk= github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE= github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI= +github.com/aws/aws-sdk-go-v2/service/signin v1.8.0 h1:bSvKIoLuRGFqGwASgeCQncCJDi9YKKBDEmCEZzOX1uU= +github.com/aws/aws-sdk-go-v2/service/signin v1.8.0/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI= github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM= github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8= +github.com/aws/aws-sdk-go-v2/service/sso v1.36.0 h1:iivsh357VnfIc18IFWSuoyQEluf8frfWf4cL2Y0JUQw= +github.com/aws/aws-sdk-go-v2/service/sso v1.36.0/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0 h1:wVxM3QzSKIK8tSN6OGgezp9OK91lCLH2zhmRInN9rFM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c= github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0= github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk= +github.com/aws/aws-sdk-go-v2/service/sts v1.48.0 h1:RzZVCzYM19vhJCT5s6vO2wN8ie770Li/TmbAZ9B6N7E= +github.com/aws/aws-sdk-go-v2/service/sts v1.48.0/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk= github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= @@ -250,6 +270,8 @@ golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU= golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -266,12 +288,20 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.295.0 h1:SSqFeEVjnK5SKo6t7D0E0M7EfX8SP7K0+OJd2Ly5FzU= google.golang.org/api v0.295.0/go.mod h1:02qB8+Ox1ZFzcaKFMguy1nQLJmSIyvV6Ff4txJEXtl4= +google.golang.org/api v0.297.0 h1:WktxTsnnx0yZNnsR6j0q6hR21RnnK81FHTOPy/ux4OE= +google.golang.org/api v0.297.0/go.mod h1:S4m8x0M6OkQpkOzGk1y9JG2sm4fFQrMh6dxzjCTszhE= google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 h1:jPP56YzdY899KJ5W7efXHt/CkjlVfAaoFOwdi/IEAFA= google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5/go.mod h1:gutZdP0DwAHp4vu5WaXgEK7tjsJ77ZEqzlOFWGZGziE= +google.golang.org/genproto v0.0.0-20260831171406-18b4a7587f8a h1:d5Vqs7VNOkWqGjSrJDOsHsLOm9Z0lAn+xHX7TCEcFmk= +google.golang.org/genproto v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:fzLclyAUFitqvij38hIBk5yYbwpWhLTxTRz6cpnfyvc= google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU= google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0= +google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs= +google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= diff --git a/go.work.sum b/go.work.sum index e9b12b3..daf5593 100644 --- a/go.work.sum +++ b/go.work.sum @@ -552,6 +552,7 @@ golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=