- Add R2 client module (cmd/acb-evolver/internal/live/r2.go) with S3-compatible uploads to Cloudflare R2 - UploadLiveJSON() uploads evolution state to evolution/live.json with Cache-Control: max-age=10 for near-real-time updates - Add -r2 and -r2-only flags to live-export subcommand - Add tests for R2 config validation and credential handling - Update frontend to fetch live data from R2 URL instead of Pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
125 lines
3.3 KiB
Go
125 lines
3.3 KiB
Go
// Package live provides R2 upload for the evolution live.json feed.
|
|
package live
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"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"
|
|
)
|
|
|
|
// R2Config holds R2 configuration for live.json uploads.
|
|
type R2Config struct {
|
|
AccessKey string
|
|
SecretKey string
|
|
Endpoint string
|
|
Bucket string
|
|
}
|
|
|
|
// R2ConfigFromEnv loads R2 configuration from environment variables.
|
|
func R2ConfigFromEnv() *R2Config {
|
|
return &R2Config{
|
|
AccessKey: os.Getenv("ACB_R2_ACCESS_KEY"),
|
|
SecretKey: os.Getenv("ACB_R2_SECRET_KEY"),
|
|
Endpoint: getEnvOrDefault("ACB_R2_ENDPOINT", ""),
|
|
Bucket: os.Getenv("ACB_R2_BUCKET"),
|
|
}
|
|
}
|
|
|
|
// HasCredentials returns true if R2 credentials are configured.
|
|
func (c *R2Config) HasCredentials() bool {
|
|
return c.AccessKey != "" && c.SecretKey != "" && c.Endpoint != "" && c.Bucket != ""
|
|
}
|
|
|
|
// R2Client handles R2 bucket operations for live.json uploads.
|
|
type R2Client struct {
|
|
client *s3.Client
|
|
bucket string
|
|
endpoint string
|
|
}
|
|
|
|
// NewR2Client creates a new R2 client.
|
|
func NewR2Client(cfg *R2Config) (*R2Client, error) {
|
|
if !cfg.HasCredentials() {
|
|
return nil, fmt.Errorf("R2 credentials not configured")
|
|
}
|
|
|
|
// Create custom endpoint resolver for R2
|
|
customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
|
return aws.Endpoint{
|
|
URL: cfg.Endpoint,
|
|
SigningRegion: "auto",
|
|
}, nil
|
|
})
|
|
|
|
// Load AWS config with R2 credentials
|
|
awsCfg, err := config.LoadDefaultConfig(context.TODO(),
|
|
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
|
cfg.AccessKey,
|
|
cfg.SecretKey,
|
|
"",
|
|
)),
|
|
config.WithEndpointResolverWithOptions(customResolver),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load AWS config: %w", err)
|
|
}
|
|
|
|
return &R2Client{
|
|
client: s3.NewFromConfig(awsCfg),
|
|
bucket: cfg.Bucket,
|
|
endpoint: cfg.Endpoint,
|
|
}, nil
|
|
}
|
|
|
|
// UploadLiveJSON uploads the live.json data to R2 at evolution/live.json.
|
|
// The file is served with Cache-Control: max-age=10 for near-real-time updates.
|
|
func (c *R2Client) UploadLiveJSON(ctx context.Context, data *LiveData) error {
|
|
b, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal: %w", err)
|
|
}
|
|
|
|
_, err = c.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(c.bucket),
|
|
Key: aws.String("evolution/live.json"),
|
|
Body: bytes.NewReader(b),
|
|
ContentType: aws.String("application/json"),
|
|
CacheControl: aws.String("public, max-age=10"),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("put object: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Upload uploads data to R2 at the specified key.
|
|
func (c *R2Client) Upload(ctx context.Context, key string, data []byte, contentType string) error {
|
|
_, err := c.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(c.bucket),
|
|
Key: aws.String(key),
|
|
Body: bytes.NewReader(data),
|
|
ContentType: aws.String(contentType),
|
|
CacheControl: aws.String("public, max-age=10"),
|
|
})
|
|
return err
|
|
}
|
|
|
|
// Endpoint returns the R2 endpoint URL.
|
|
func (c *R2Client) Endpoint() string {
|
|
return c.endpoint
|
|
}
|
|
|
|
func getEnvOrDefault(key, defaultValue string) string {
|
|
if val := os.Getenv(key); val != "" {
|
|
return val
|
|
}
|
|
return defaultValue
|
|
}
|