diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-07-08 15:01:05 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-07-08 15:01:05 -0700 |
| commit | 4bd4c14daff4cb8807a91305239cae1e8544f276 (patch) | |
| tree | 37de49c06efa627748f0581f6531b35f28485158 /cmd | |
| parent | 52eb3389f074e8ac28fa1eae90847fb7536acdd8 (diff) | |
Add Cloudflare R2 file library storage backend
Implement R2FileStore, a FileStore backed by Cloudflare R2 via its
S3-compatible API, selectable with --file-store r2 and configured through
R2_* environment variables.
The store follows the MemFileStore model: a flat keyspace where directories
are derived from key prefixes, with zero-byte marker objects so empty folders
persist, and errors.ErrUnsupported for symlink/alias operations. Because R2
has no append, in-progress .incomplete uploads are routed to a local staging
directory (real O_APPEND and size-based resume) and promoted to a finished R2
object on the terminal upload-commit Rename; all other paths live in R2.
A narrow s3API seam plus an s3Uploader interface make the backend unit-testable
against an in-memory fake without a network. Adds a user setup guide at
docs/r2-file-store.md.
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/mobius-hotline-server/main.go | 59 |
1 files changed, 58 insertions, 1 deletions
diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go index 35fbcf9..9e59fa8 100644 --- a/cmd/mobius-hotline-server/main.go +++ b/cmd/mobius-hotline-server/main.go @@ -11,8 +11,12 @@ import ( "os" "os/signal" "path" + "path/filepath" "syscall" + 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" "github.com/jhalter/mobius/hotline" "github.com/jhalter/mobius/internal/mobius" "github.com/oleksandr/bonjour" @@ -49,7 +53,7 @@ func main() { tlsCert := flag.String("tls-cert", "", "Path to TLS certificate file") tlsKey := flag.String("tls-key", "", "Path to TLS key file") tlsPort := flag.Int("tls-port", 5600, "Base TLS port. TLS file transfer port is base + 1.") - fileStoreBackend := flag.String("file-store", "os", "File library storage backend: os (default) or memory") + fileStoreBackend := flag.String("file-store", "os", "File library storage backend: os (default), memory, or r2 (Cloudflare R2, configured via R2_* env vars)") flag.Parse() @@ -113,6 +117,14 @@ func main() { case "memory": opts = append(opts, hotline.WithFileStore(hotline.NewMemFileStore())) slogger.Warn("Using in-memory file store; uploaded files are not persisted") + case "r2": + r2Store, err := newR2FileStore(ctx) + if err != nil { + slogger.Error("Error configuring Cloudflare R2 file store", "err", err) + os.Exit(1) + } + opts = append(opts, hotline.WithFileStore(r2Store)) + slogger.Info("Using Cloudflare R2 file store", "bucket", os.Getenv("R2_BUCKET")) default: slogger.Error("Unknown file-store backend", "backend", *fileStoreBackend) os.Exit(1) @@ -325,6 +337,51 @@ func copyDirRecursive(src, dst string) error { return nil } +// newR2FileStore builds a Cloudflare R2-backed file store from R2_* environment variables. +// +// Required: R2_BUCKET and credentials (R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY), plus the endpoint, +// given either as R2_ACCOUNT_ID (from which the standard R2 endpoint is derived) or as an explicit +// R2_ENDPOINT. Optional: R2_PREFIX (a key prefix within the bucket) and R2_STAGING_DIR (local temp +// dir for in-progress .incomplete uploads; defaults to <os.TempDir>/mobius-uploads). +func newR2FileStore(ctx context.Context) (hotline.FileStore, error) { + bucket := os.Getenv("R2_BUCKET") + accessKey := os.Getenv("R2_ACCESS_KEY_ID") + secretKey := os.Getenv("R2_SECRET_ACCESS_KEY") + if bucket == "" || accessKey == "" || secretKey == "" { + return nil, errors.New("R2_BUCKET, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY must be set") + } + + endpoint := os.Getenv("R2_ENDPOINT") + if endpoint == "" { + accountID := os.Getenv("R2_ACCOUNT_ID") + if accountID == "" { + return nil, errors.New("either R2_ENDPOINT or R2_ACCOUNT_ID must be set") + } + endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID) + } + + stagingDir := os.Getenv("R2_STAGING_DIR") + if stagingDir == "" { + stagingDir = filepath.Join(os.TempDir(), "mobius-uploads") + } + + cfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion("auto"), + awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""), + ), + ) + if err != nil { + return nil, fmt.Errorf("load AWS config: %w", err) + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = &endpoint + }) + + return hotline.NewR2FileStore(client, bucket, os.Getenv("R2_PREFIX"), stagingDir), nil +} + // copyFile copies a single file from embedded filesystem to local filesystem. func copyFile(src, dst string) error { srcFile, err := cfgTemplate.Open(src) |