Files
warmbly/internal/infrastructure/storage/s3_store.go
T
Matthew Meszaros 368c1f7886 infra(storage): Store interface + Filesystem impl + S3-compatible factory
One Store interface (Get/Put/Delete/Has/PresignedGetURL/Name) covers
both the AWS S3 client (now exposes high-level methods alongside the
legacy embedded *s3.Client) and a new FilesystemStore for self-hosters.

FilesystemStore writes atomically via temp-file + rename, rejects '..'
in keys before path normalization, returns ErrUnsupported from
PresignedGetURL.

The S3 impl works against AWS S3, MinIO, Cloudflare R2, Backblaze B2,
and Hetzner Object Storage via standard AWS endpoint-URL config.

Factory NewFromEnv selects via BLOB_PROVIDER (defaults to s3).

14 tests cover round-trip, ErrNotFound, atomic-write cleanup, traversal
rejection, factory env-selection paths.
2026-05-27 14:41:40 +00:00

75 lines
1.9 KiB
Go

package storage
import (
"context"
"errors"
"io"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
// High-level Store methods on *Client. These let callers depend on the
// storage.Store interface so the same code works against AWS S3, MinIO,
// Cloudflare R2, Backblaze B2, Hetzner Object Storage, or the filesystem
// backend without conditional logic.
//
// The lower-level Client.GetObject / PutObject / etc. (via embedded *s3.Client)
// remain available for existing call sites that haven't been migrated yet.
func (c *Client) Name() string { return "s3" }
func (c *Client) Get(ctx context.Context, key string) (io.ReadCloser, error) {
out, err := c.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.Bucket),
Key: aws.String(key),
})
if err != nil {
var nsk *types.NoSuchKey
if errors.As(err, &nsk) {
return nil, ErrNotFound
}
return nil, err
}
return out.Body, nil
}
func (c *Client) Put(ctx context.Context, key string, body io.Reader, contentType string) error {
in := &s3.PutObjectInput{
Bucket: aws.String(c.Bucket),
Key: aws.String(key),
Body: body,
}
if contentType != "" {
in.ContentType = aws.String(contentType)
}
_, err := c.PutObject(ctx, in)
return err
}
func (c *Client) Delete(ctx context.Context, key string) error {
_, err := c.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(c.Bucket),
Key: aws.String(key),
})
return err
}
func (c *Client) Has(ctx context.Context, key string) (bool, error) {
return c.Exists(ctx, c.Bucket, key)
}
func (c *Client) PresignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
ps := s3.NewPresignClient(c.Client)
out, err := ps.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.Bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(ttl))
if err != nil {
return "", err
}
return out.URL, nil
}