| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/BurntSushi/toml" |
| 11 | "github.com/stevedylandev/andromeda/pkg/config" |
| 12 | ) |
| 13 | |
| 14 | // ClientConfig is the on-disk client config for the blobs TUI/CLI. |
| 15 | // Server still reads from env/.env directly; this is purely for client use |
| 16 | // when running outside the server's working directory. |
| 17 | type ClientConfig struct { |
| 18 | Endpoint string `toml:"endpoint"` |
| 19 | Region string `toml:"region"` |
| 20 | AccessKeyID string `toml:"access_key_id"` |
| 21 | SecretAccessKey string `toml:"secret_access_key"` |
| 22 | R2AccountID string `toml:"r2_account_id,omitempty"` |
| 23 | DefaultBucket string `toml:"default_bucket,omitempty"` |
| 24 | PublicURLs map[string]string `toml:"public_urls,omitempty"` |
| 25 | PresignTTLSec int `toml:"presign_ttl_seconds,omitempty"` |
| 26 | } |
| 27 | |
| 28 | // ClientFlags holds flag overrides parsed from argv. |
| 29 | type ClientFlags struct { |
| 30 | Bucket string |
| 31 | Prefix string |
| 32 | Key string |
| 33 | } |
| 34 | |
| 35 | func clientConfigPath() (string, error) { |
| 36 | dir, err := os.UserConfigDir() |
| 37 | if err != nil { |
| 38 | return "", err |
| 39 | } |
| 40 | return filepath.Join(dir, "blobs", "config.toml"), nil |
| 41 | } |
| 42 | |
| 43 | // LoadClientConfig merges (precedence: flags > env > .env-in-cwd > toml > defaults). |
| 44 | func LoadClientConfig(flags ClientFlags) (ClientConfig, error) { |
| 45 | // .env (best-effort, matches server behavior) |
| 46 | config.LoadDotEnv(".env") |
| 47 | |
| 48 | cfg := ClientConfig{} |
| 49 | if path, err := clientConfigPath(); err == nil { |
| 50 | if data, rerr := os.ReadFile(path); rerr == nil { |
| 51 | _ = toml.Unmarshal(data, &cfg) |
| 52 | } else if !os.IsNotExist(rerr) { |
| 53 | return cfg, rerr |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // env overrides toml |
| 58 | if v := os.Getenv("S3_ENDPOINT"); v != "" { |
| 59 | cfg.Endpoint = v |
| 60 | } |
| 61 | if v := os.Getenv("R2_ACCOUNT_ID"); v != "" { |
| 62 | cfg.R2AccountID = v |
| 63 | } |
| 64 | if cfg.Endpoint == "" && cfg.R2AccountID != "" { |
| 65 | cfg.Endpoint = "https://" + cfg.R2AccountID + ".r2.cloudflarestorage.com" |
| 66 | } |
| 67 | if v := os.Getenv("S3_REGION"); v != "" { |
| 68 | cfg.Region = v |
| 69 | } |
| 70 | if cfg.Region == "" { |
| 71 | cfg.Region = "auto" |
| 72 | } |
| 73 | if v := config.Getenv("S3_ACCESS_KEY_ID", os.Getenv("R2_ACCESS_KEY_ID")); v != "" { |
| 74 | cfg.AccessKeyID = v |
| 75 | } |
| 76 | if v := config.Getenv("S3_SECRET_ACCESS_KEY", os.Getenv("R2_SECRET_ACCESS_KEY")); v != "" { |
| 77 | cfg.SecretAccessKey = v |
| 78 | } |
| 79 | if v := os.Getenv("BLOBS_DEFAULT_BUCKET"); v != "" { |
| 80 | cfg.DefaultBucket = v |
| 81 | } |
| 82 | if v := os.Getenv("BLOBS_PUBLIC_URLS"); v != "" { |
| 83 | merged := parsePublicURLs(v) |
| 84 | if cfg.PublicURLs == nil { |
| 85 | cfg.PublicURLs = map[string]string{} |
| 86 | } |
| 87 | for k, val := range merged { |
| 88 | cfg.PublicURLs[k] = val |
| 89 | } |
| 90 | } |
| 91 | if v := config.GetenvInt("BLOBS_PRESIGN_TTL_SECONDS", 0); v > 0 { |
| 92 | cfg.PresignTTLSec = v |
| 93 | } |
| 94 | if cfg.PresignTTLSec <= 0 { |
| 95 | cfg.PresignTTLSec = 3600 |
| 96 | } |
| 97 | |
| 98 | // flag overrides |
| 99 | if flags.Bucket != "" { |
| 100 | cfg.DefaultBucket = flags.Bucket |
| 101 | } |
| 102 | |
| 103 | return cfg, nil |
| 104 | } |
| 105 | |
| 106 | // SaveClientConfig writes cfg to ~/.config/blobs/config.toml (0600). |
| 107 | func SaveClientConfig(cfg ClientConfig) error { |
| 108 | path, err := clientConfigPath() |
| 109 | if err != nil { |
| 110 | return err |
| 111 | } |
| 112 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 113 | return err |
| 114 | } |
| 115 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) |
| 116 | if err != nil { |
| 117 | return err |
| 118 | } |
| 119 | defer f.Close() |
| 120 | return toml.NewEncoder(f).Encode(cfg) |
| 121 | } |
| 122 | |
| 123 | // NewS3FromConfig builds an S3 client from ClientConfig. |
| 124 | func NewS3FromConfig(cfg ClientConfig) (*S3Client, error) { |
| 125 | if cfg.Endpoint == "" { |
| 126 | return nil, fmt.Errorf("S3 endpoint not configured (set S3_ENDPOINT, R2_ACCOUNT_ID, or run `blobs auth`)") |
| 127 | } |
| 128 | pubs := cfg.PublicURLs |
| 129 | if pubs == nil { |
| 130 | pubs = map[string]string{} |
| 131 | } |
| 132 | ttl := time.Duration(cfg.PresignTTLSec) * time.Second |
| 133 | return NewS3Client(cfg.Endpoint, cfg.Region, cfg.AccessKeyID, cfg.SecretAccessKey, pubs, ttl) |
| 134 | } |
| 135 | |
| 136 | // ResolveURL returns a public URL if the bucket is mapped, else a presigned URL. |
| 137 | func ResolveURL(ctx context.Context, s3 *S3Client, bucket, key string) (string, error) { |
| 138 | if u, ok := s3.PublicURL(bucket, key); ok { |
| 139 | return u, nil |
| 140 | } |
| 141 | return s3.PresignGet(ctx, bucket, key) |
| 142 | } |