aboutsummaryrefslogtreecommitdiff
path: root/cmd/mobius-hotline-server
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2026-07-08 15:01:05 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2026-07-08 15:01:05 -0700
commit4bd4c14daff4cb8807a91305239cae1e8544f276 (patch)
tree37de49c06efa627748f0581f6531b35f28485158 /cmd/mobius-hotline-server
parent52eb3389f074e8ac28fa1eae90847fb7536acdd8 (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/mobius-hotline-server')
-rw-r--r--cmd/mobius-hotline-server/main.go59
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)