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