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 }