Files
spsays-bff/internal/repository/r2.go
T

229 lines
7.6 KiB
Go

package repository
import (
"bytes"
"context"
"errors"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// R2 wraps an S3 client pointed at Cloudflare's R2 endpoint.
//
// We use R2 for avatar uploads through presigned PUT URLs:
//
// frontend asks BFF for a presigned URL ─► BFF returns URL + final public URL
// frontend PUTs the file directly to R2 ─► R2 stores it
// frontend POSTs the public URL back to BFF profile-update endpoint
//
// The BFF never proxies the file bytes; bandwidth stays browser↔R2.
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.
type R2Config struct {
AccountID string
AccessKeyID string
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.
func (c R2Config) Configured() bool {
return c.AccountID != "" && c.AccessKeyID != "" && c.SecretAccessKey != "" &&
c.Bucket != "" && c.PublicURL != ""
}
// NewR2 builds an S3 client pointed at the per-account R2 endpoint.
// Returns (nil, nil) if cfg is not Configured — the avatar route will then
// return 503 instead of panicking. That keeps the BFF bootable without R2.
func NewR2(cfg R2Config) (*R2, error) {
if !cfg.Configured() {
return nil, nil
}
endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.AccountID)
creds := credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")
client := s3.New(s3.Options{
Region: "auto", // R2 ignores region but the SDK requires one
Credentials: creds,
BaseEndpoint: aws.String(endpoint),
UsePathStyle: true,
})
return &R2{
client: client,
bucket: cfg.Bucket,
publicBase: trimTrailingSlash(cfg.PublicURL),
privateBucket: cfg.PrivateBucket,
}, nil
}
// PresignPut returns a one-shot PUT URL the browser can use to upload a file
// directly to R2, plus the final public URL where the object will be served.
//
// expiry caps how long the upload URL is valid; 5 minutes is plenty and
// limits replay if the URL leaks.
func (r *R2) PresignPut(ctx context.Context, key, contentType string, expiry time.Duration) (uploadURL, publicURL string, err error) {
if r == nil {
return "", "", errors.New("R2 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.bucket),
Key: aws.String(key),
ContentType: aws.String(contentType),
}, s3.WithPresignExpires(expiry))
if err != nil {
return "", "", fmt.Errorf("presign put: %w", err)
}
return req.URL, fmt.Sprintf("%s/%s", r.publicBase, key), nil
}
// PutObject uploads bytes to R2 server-side (used by the one-off rewards
// import tool, where the BFF itself holds the file). Returns the public URL.
func (r *R2) PutObject(ctx context.Context, key, contentType string, body []byte) (string, error) {
if r == nil {
return "", errors.New("R2 not configured")
}
if key == "" {
return "", errors.New("empty key")
}
_, err := r.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(r.bucket),
Key: aws.String(key),
Body: bytes.NewReader(body),
ContentType: aws.String(contentType),
})
if err != nil {
return "", fmt.Errorf("put object %q: %w", key, err)
}
return r.PublicURL(key), nil
}
// PublicURL builds the public URL for an arbitrary key. Useful when reading
// existing user.avatar_url values back.
func (r *R2) PublicURL(key string) string {
if r == nil {
return ""
}
return fmt.Sprintf("%s/%s", r.publicBase, key)
}
func trimTrailingSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
}
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
}