/ src / config.go
config.go
  1  package git_pages
  2  
  3  import (
  4  	"bytes"
  5  	"encoding/json"
  6  	"fmt"
  7  	"os"
  8  	"reflect"
  9  	"slices"
 10  	"strconv"
 11  	"strings"
 12  	"time"
 13  
 14  	"github.com/c2h5oh/datasize"
 15  	"github.com/creasty/defaults"
 16  	"github.com/pelletier/go-toml/v2"
 17  )
 18  
 19  // For some reason, the standard `time.Duration` type doesn't implement the standard
 20  // `encoding.{TextMarshaler,TextUnmarshaler}` interfaces.
 21  type Duration time.Duration
 22  
 23  func (t Duration) String() string {
 24  	return fmt.Sprint(time.Duration(t))
 25  }
 26  
 27  func (t *Duration) UnmarshalText(data []byte) (err error) {
 28  	u, err := time.ParseDuration(string(data))
 29  	*t = Duration(u)
 30  	return
 31  }
 32  
 33  func (t *Duration) MarshalText() ([]byte, error) {
 34  	return []byte(t.String()), nil
 35  }
 36  
 37  type Config struct {
 38  	Insecure      bool                `toml:"-" env:"insecure"`
 39  	Features      []string            `toml:"features"`
 40  	LogFormat     string              `toml:"log-format" default:"text"`
 41  	Server        ServerConfig        `toml:"server"`
 42  	Wildcard      []WildcardConfig    `toml:"wildcard"`
 43  	Storage       StorageConfig       `toml:"storage"`
 44  	Limits        LimitsConfig        `toml:"limits"`
 45  	Observability ObservabilityConfig `toml:"observability"`
 46  }
 47  
 48  type ServerConfig struct {
 49  	Pages   string `toml:"pages" default:"tcp/:3000"`
 50  	Caddy   string `toml:"caddy" default:"tcp/:3001"`
 51  	Metrics string `toml:"metrics" default:"tcp/:3002"`
 52  }
 53  
 54  type WildcardConfig struct {
 55  	Domain          string   `toml:"domain"`
 56  	CloneURL        string   `toml:"clone-url"`
 57  	IndexRepos      []string `toml:"index-repos" default:"[]"`
 58  	FallbackProxyTo string   `toml:"fallback-proxy-to"`
 59  }
 60  
 61  type CacheConfig struct {
 62  	MaxSize  datasize.ByteSize `toml:"max-size"`
 63  	MaxAge   Duration          `toml:"max-age"`
 64  	MaxStale Duration          `toml:"max-stale"`
 65  }
 66  
 67  type StorageConfig struct {
 68  	Type string   `toml:"type" default:"fs"`
 69  	FS   FSConfig `toml:"fs"  default:"{\"Root\":\"./data\"}"`
 70  	S3   S3Config `toml:"s3"`
 71  }
 72  
 73  type FSConfig struct {
 74  	Root string `toml:"root"`
 75  }
 76  
 77  type S3Config struct {
 78  	Endpoint        string      `toml:"endpoint"`
 79  	Insecure        bool        `toml:"insecure"`
 80  	AccessKeyID     string      `toml:"access-key-id"`
 81  	SecretAccessKey string      `toml:"secret-access-key"`
 82  	Region          string      `toml:"region"`
 83  	Bucket          string      `toml:"bucket"`
 84  	BlobCache       CacheConfig `toml:"blob-cache" default:"{\"MaxSize\":\"256MB\"}"`
 85  	SiteCache       CacheConfig `toml:"site-cache" default:"{\"MaxAge\":\"60s\",\"MaxStale\":\"1h\",\"MaxSize\":\"16MB\"}"`
 86  }
 87  
 88  type LimitsConfig struct {
 89  	// Maximum size of a single published site. Also used to limit the size of archive
 90  	// uploads and other similar overconsumption conditions.
 91  	MaxSiteSize datasize.ByteSize `toml:"max-site-size" default:"128M"`
 92  	// Maximum size of a single site manifest, computed over its binary Protobuf
 93  	// serialization.
 94  	MaxManifestSize datasize.ByteSize `toml:"max-manifest-size" default:"1M"`
 95  	// Maximum size of a file that will still be inlined into the site manifest.
 96  	MaxInlineFileSize datasize.ByteSize `toml:"max-inline-file-size" default:"256B"`
 97  	// Maximum size of a Git object that will be cached in memory during Git operations.
 98  	GitLargeObjectThreshold datasize.ByteSize `toml:"git-large-object-threshold" default:"1M"`
 99  	// Maximum number of symbolic link traversals before the path is considered unreachable.
100  	MaxSymlinkDepth uint `toml:"max-symlink-depth" default:"16"`
101  	// Maximum time that an update operation (PUT or POST request) could take before being
102  	// interrupted.
103  	UpdateTimeout Duration `toml:"update-timeout" default:"60s"`
104  	// Soft limit on Go heap size, expressed as a fraction of total available RAM.
105  	MaxHeapSizeRatio float64 `toml:"max-heap-size-ratio" default:"0.5"`
106  	// List of domains unconditionally forbidden for uploads.
107  	ForbiddenDomains []string `toml:"forbidden-domains"`
108  	// List of allowed repository URL prefixes. Setting this option prohibits uploading archives.
109  	AllowedRepositoryURLPrefixes []string `toml:"allowed-repository-url-prefixes"`
110  }
111  
112  type ObservabilityConfig struct {
113  	// Minimum duration for an HTTP request transaction to be unconditionally sampled.
114  	SlowResponseThreshold Duration `toml:"slow-response-threshold" default:"500ms"`
115  }
116  
117  func (config *Config) DebugJSON() string {
118  	result, err := json.MarshalIndent(config, "", "  ")
119  	if err != nil {
120  		panic(err)
121  	}
122  	return string(result)
123  }
124  
125  func (config *Config) Feature(name string) bool {
126  	return slices.Contains(config.Features, name)
127  }
128  
129  type walkConfigState struct {
130  	config    reflect.Value
131  	scopeType reflect.Type
132  	index     []int
133  	segments  []string
134  }
135  
136  func walkConfigScope(scopeState walkConfigState, onKey func(string, reflect.Value) error) (err error) {
137  	for _, field := range reflect.VisibleFields(scopeState.scopeType) {
138  		fieldState := walkConfigState{config: scopeState.config}
139  		fieldState.scopeType = field.Type
140  		fieldState.index = append(scopeState.index, field.Index...)
141  		var tagValue, ok = "", false
142  		if tagValue, ok = field.Tag.Lookup("env"); !ok {
143  			if tagValue, ok = field.Tag.Lookup("toml"); !ok {
144  				continue // implicit skip
145  			}
146  		} else if tagValue == "-" {
147  			continue // explicit skip
148  		}
149  		fieldSegment := strings.ReplaceAll(strings.ToUpper(tagValue), "-", "_")
150  		fieldState.segments = append(scopeState.segments, fieldSegment)
151  		switch field.Type.Kind() {
152  		case reflect.Struct:
153  			err = walkConfigScope(fieldState, onKey)
154  		default:
155  			err = onKey(
156  				strings.Join(fieldState.segments, "_"),
157  				scopeState.config.FieldByIndex(fieldState.index),
158  			)
159  		}
160  		if err != nil {
161  			return
162  		}
163  	}
164  	return
165  }
166  
167  func walkConfig(config *Config, onKey func(string, reflect.Value) error) error {
168  	state := walkConfigState{
169  		config:    reflect.ValueOf(config).Elem(),
170  		scopeType: reflect.TypeOf(config).Elem(),
171  		index:     []int{},
172  		segments:  []string{"PAGES"},
173  	}
174  	return walkConfigScope(state, onKey)
175  }
176  
177  func setConfigValue(reflValue reflect.Value, repr string) (err error) {
178  	valueAny := reflValue.Interface()
179  	switch valueCast := valueAny.(type) {
180  	case string:
181  		reflValue.SetString(repr)
182  	case []string:
183  		reflValue.Set(reflect.ValueOf(strings.Split(repr, ",")))
184  	case bool:
185  		if valueCast, err = strconv.ParseBool(repr); err == nil {
186  			reflValue.SetBool(valueCast)
187  		}
188  	case uint:
189  		var parsed uint64
190  		if parsed, err = strconv.ParseUint(repr, 10, strconv.IntSize); err == nil {
191  			reflValue.SetUint(parsed)
192  		}
193  	case float64:
194  		if valueCast, err = strconv.ParseFloat(repr, 64); err == nil {
195  			reflValue.SetFloat(valueCast)
196  		}
197  	case datasize.ByteSize:
198  		if valueCast, err = datasize.ParseString(repr); err == nil {
199  			reflValue.Set(reflect.ValueOf(valueCast))
200  		}
201  	case time.Duration:
202  		if valueCast, err = time.ParseDuration(repr); err == nil {
203  			reflValue.Set(reflect.ValueOf(valueCast))
204  		}
205  	case Duration:
206  		var parsed time.Duration
207  		if parsed, err = time.ParseDuration(repr); err == nil {
208  			reflValue.Set(reflect.ValueOf(Duration(parsed)))
209  		}
210  	case []WildcardConfig:
211  		var parsed []*WildcardConfig
212  		decoder := json.NewDecoder(bytes.NewReader([]byte(repr)))
213  		decoder.DisallowUnknownFields()
214  		if err = decoder.Decode(&parsed); err == nil {
215  			var assigned []WildcardConfig
216  			for _, wildcard := range parsed {
217  				defaults.MustSet(wildcard)
218  				assigned = append(assigned, *wildcard)
219  			}
220  			reflValue.Set(reflect.ValueOf(assigned))
221  		}
222  	default:
223  		panic("unhandled config value type")
224  	}
225  	return err
226  }
227  
228  func PrintConfigEnvVars() {
229  	config := Config{}
230  	defaults.MustSet(&config)
231  
232  	walkConfig(&config, func(envName string, reflValue reflect.Value) (err error) {
233  		value := reflValue.Interface()
234  		reprBefore := fmt.Sprint(value)
235  		fmt.Printf("%s %T = %q\n", envName, value, reprBefore)
236  		// make sure that the value, at least, roundtrips
237  		setConfigValue(reflValue, reprBefore)
238  		reprAfter := fmt.Sprint(value)
239  		if reprBefore != reprAfter {
240  			panic("failed to roundtrip config value")
241  		}
242  		return
243  	})
244  }
245  
246  func Configure(tomlPath string) (config *Config, err error) {
247  	// start with an all-default configuration
248  	config = new(Config)
249  	defaults.MustSet(config)
250  
251  	// inject values from `config.toml`
252  	if tomlPath != "" {
253  		var file *os.File
254  		file, err = os.Open(tomlPath)
255  		if err != nil {
256  			return
257  		}
258  		defer file.Close()
259  
260  		decoder := toml.NewDecoder(file)
261  		decoder.DisallowUnknownFields()
262  		decoder.EnableUnmarshalerInterface()
263  		if err = decoder.Decode(&config); err != nil {
264  			return
265  		}
266  	}
267  
268  	// inject values from the environment, overriding everything else
269  	err = walkConfig(config, func(envName string, reflValue reflect.Value) error {
270  		if envValue, found := os.LookupEnv(envName); found {
271  			return setConfigValue(reflValue, envValue)
272  		}
273  		return nil
274  	})
275  
276  	return
277  }