apps/blobs/clientconfig.go 3.9 K raw
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
}