132 lines
3.8 KiB
Go
132 lines
3.8 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
|
|
}
|
|
|
|
// 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
|
|
}
|