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 } // R2Config captures the env vars needed to talk to R2. type R2Config struct { AccountID string AccessKeyID string SecretAccessKey string Bucket string PublicURL 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), }, 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 }