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 }