229 lines
7.6 KiB
Go
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
|
|
}
|