feat: add private r2 bucket
This commit is contained in:
+100
-3
@@ -25,6 +25,11 @@ type R2 struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
publicBase string // R2_PUBLIC_URL, no trailing slash
|
||||
|
||||
// privateBucket is a separate, non-public bucket for objects that must
|
||||
// only be reachable through a presigned GET (see PresignGet). Empty when
|
||||
// unconfigured, which makes PrivateConfigured report false.
|
||||
privateBucket string
|
||||
}
|
||||
|
||||
// R2Config captures the env vars needed to talk to R2.
|
||||
@@ -34,6 +39,9 @@ type R2Config struct {
|
||||
SecretAccessKey string
|
||||
Bucket string
|
||||
PublicURL string
|
||||
// PrivateBucket is optional: absent means private media is disabled,
|
||||
// while the public bucket keeps working.
|
||||
PrivateBucket string
|
||||
}
|
||||
|
||||
// Configured returns true if every required field is present.
|
||||
@@ -61,9 +69,10 @@ func NewR2(cfg R2Config) (*R2, error) {
|
||||
})
|
||||
|
||||
return &R2{
|
||||
client: client,
|
||||
bucket: cfg.Bucket,
|
||||
publicBase: trimTrailingSlash(cfg.PublicURL),
|
||||
client: client,
|
||||
bucket: cfg.Bucket,
|
||||
publicBase: trimTrailingSlash(cfg.PublicURL),
|
||||
privateBucket: cfg.PrivateBucket,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -129,3 +138,91 @@ func trimTrailingSlash(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private media
|
||||
//
|
||||
// Objects in the public bucket are world-readable by URL — fine for avatars
|
||||
// and post images, wrong for anything private (DM attachments). R2 public
|
||||
// access is per-bucket, not per-prefix, so private objects live in a
|
||||
// SEPARATE bucket that has no public access and no custom domain. The only
|
||||
// way to read them is a short-lived presigned GET, which the BFF issues after
|
||||
// checking that the caller is allowed to see that particular object.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PrivateConfigured reports whether the private bucket is available. Callers
|
||||
// use it to return 503 rather than silently falling back to public storage —
|
||||
// a fallback there would leak the very objects this bucket exists to protect.
|
||||
func (r *R2) PrivateConfigured() bool {
|
||||
return r != nil && r.privateBucket != ""
|
||||
}
|
||||
|
||||
// PresignPutPrivate returns a one-shot PUT URL for the PRIVATE bucket.
|
||||
//
|
||||
// Unlike PresignPut it deliberately returns no public URL: there isn't one,
|
||||
// and handing callers a URL-shaped string for a private object invites it
|
||||
// being persisted and treated as fetchable. Store the key instead, and mint a
|
||||
// read URL per request with PresignGet.
|
||||
func (r *R2) PresignPutPrivate(ctx context.Context, key, contentType string, expiry time.Duration) (uploadURL string, err error) {
|
||||
if !r.PrivateConfigured() {
|
||||
return "", errors.New("private media storage is not configured")
|
||||
}
|
||||
if key == "" {
|
||||
return "", errors.New("empty key")
|
||||
}
|
||||
|
||||
presigner := s3.NewPresignClient(r.client)
|
||||
req, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(r.privateBucket),
|
||||
Key: aws.String(key),
|
||||
ContentType: aws.String(contentType),
|
||||
}, s3.WithPresignExpires(expiry))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("presign private put: %w", err)
|
||||
}
|
||||
return req.URL, nil
|
||||
}
|
||||
|
||||
// PresignGet returns a short-lived read URL for an object in the private
|
||||
// bucket.
|
||||
//
|
||||
// Keep expiry short. The URL is a bearer credential for that one object:
|
||||
// anyone holding it can read until it expires, so it should be minted per
|
||||
// request and never persisted in a message payload or an offline cache.
|
||||
func (r *R2) PresignGet(ctx context.Context, key string, expiry time.Duration) (string, error) {
|
||||
if !r.PrivateConfigured() {
|
||||
return "", errors.New("private media storage is not configured")
|
||||
}
|
||||
if key == "" {
|
||||
return "", errors.New("empty key")
|
||||
}
|
||||
|
||||
presigner := s3.NewPresignClient(r.client)
|
||||
req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(r.privateBucket),
|
||||
Key: aws.String(key),
|
||||
}, s3.WithPresignExpires(expiry))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("presign get: %w", err)
|
||||
}
|
||||
return req.URL, nil
|
||||
}
|
||||
|
||||
// DeletePrivate removes an object from the private bucket. Retention and
|
||||
// account deletion need real erasure, not a tombstone row that leaves the
|
||||
// bytes sitting in storage.
|
||||
func (r *R2) DeletePrivate(ctx context.Context, key string) error {
|
||||
if !r.PrivateConfigured() {
|
||||
return errors.New("private media storage is not configured")
|
||||
}
|
||||
if key == "" {
|
||||
return errors.New("empty key")
|
||||
}
|
||||
if _, err := r.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(r.privateBucket),
|
||||
Key: aws.String(key),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("delete private object %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user