feat(storage): add S3/R2 abstraction with local Rustfs dev

- Add backend/internal/s3/ with build-tag pattern (dev vs prod)
- Dev: Uses local Rustfs container (S3-compatible)
- Prod: Stub for R2 Cloudflare (add AWS SDK to implement)
- Add S3 env vars to .env.example and .env
- Add Rustfs service to compose.yml
- Add Rustfs reset to local-dev-2.sh (wipes data on each run)
This commit is contained in:
2026-02-17 22:47:38 +00:00
parent d43d7ebc5e
commit dfd552b02f
8 changed files with 304 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
//go:build !dev
// +build !dev
package s3
import (
"context"
"fmt"
"io"
"log"
"os"
)
var Client Uploader
type Uploader interface {
Upload(ctx context.Context, bucket, key string, body io.Reader) error
Download(ctx context.Context, bucket, key string, w io.Writer) error
Delete(ctx context.Context, bucket, key string) error
GetURL(ctx context.Context, bucket, key string) (string, error)
}
type S3Client struct {
bucket string
endpoint string
region string
accessKey string
secretKey string
publicURL string
}
func Connect() error {
endpoint := os.Getenv("R2_ENDPOINT")
if endpoint == "" {
log.Println("WARNING: R2_ENDPOINT not set, S3 client not initialized")
return nil
}
Client = &S3Client{
endpoint: endpoint,
bucket: getEnv("R2_BUCKET", ""),
region: getEnv("AWS_REGION", "auto"),
accessKey: getEnv("R2_ACCESS_KEY", ""),
secretKey: getEnv("R2_SECRET_KEY", ""),
publicURL: getEnv("R2_PUBLIC_URL", ""),
}
log.Printf("Connected to R2: bucket=%s, endpoint=%s", Client.(*S3Client).bucket, endpoint)
return nil
}
func (s *S3Client) Upload(ctx context.Context, bucket, key string, body io.Reader) error {
return fmt.Errorf("S3 Upload not implemented: add AWS SDK v2 dependency")
}
func (s *S3Client) Download(ctx context.Context, bucket, key string, w io.Writer) error {
return fmt.Errorf("S3 Download not implemented: add AWS SDK v2 dependency")
}
func (s *S3Client) Delete(ctx context.Context, bucket, key string) error {
return fmt.Errorf("S3 Delete not implemented: add AWS SDK v2 dependency")
}
func (s *S3Client) GetURL(ctx context.Context, bucket, key string) (string, error) {
return fmt.Sprintf("%s/%s/%s", s.publicURL, bucket, key), nil
}
func getEnv(key, fallback string) string {
if val := os.Getenv(key); val != "" {
return val
}
if fallback != "" {
return fallback
}
log.Fatal("FATAL: Environment variable not set:", key)
return ""
}
+118
View File
@@ -0,0 +1,118 @@
//go:build dev
// +build dev
package s3
import (
"context"
"fmt"
"io"
"log"
"os"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
var Client Uploader
type Uploader interface {
Upload(ctx context.Context, bucket, key string, body io.Reader) error
Download(ctx context.Context, bucket, key string, w io.Writer) error
Delete(ctx context.Context, bucket, key string) error
GetURL(ctx context.Context, bucket, key string) (string, error)
}
type S3Client struct {
client *s3.Client
bucket string
publicURL string
}
func Connect() error {
endpoint := os.Getenv("S3_ENDPOINT")
if endpoint == "" {
endpoint = "http://localhost:9000"
}
accessKey := os.Getenv("S3_ACCESS_KEY")
if accessKey == "" {
accessKey = "minioadmin"
}
secretKey := os.Getenv("S3_SECRET_KEY")
if secretKey == "" {
secretKey = "minioadmin"
}
bucket := os.Getenv("S3_BUCKET")
if bucket == "" {
bucket = "crussell"
}
region := os.Getenv("AWS_REGION")
if region == "" {
region = "us-east-1"
}
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion(region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
accessKey,
secretKey,
"",
)),
)
if err != nil {
return fmt.Errorf("failed to load AWS config: %w", err)
}
Client = &S3Client{
client: s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
}),
bucket: bucket,
publicURL: endpoint,
}
log.Printf("Connected to local S3 (Rustfs): bucket=%s, endpoint=%s", bucket, endpoint)
return nil
}
func (s *S3Client) Upload(ctx context.Context, bucket, key string, body io.Reader) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: body,
})
return err
}
func (s *S3Client) Download(ctx context.Context, bucket, key string, w io.Writer) error {
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return err
}
defer result.Body.Close()
_, err = io.Copy(w, result.Body)
return err
}
func (s *S3Client) Delete(ctx context.Context, bucket, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
return err
}
func (s *S3Client) GetURL(ctx context.Context, bucket, key string) (string, error) {
return fmt.Sprintf("%s/%s/%s", s.publicURL, bucket, key), nil
}