Files
git-pages/src/config.go
Catherine 3840ba3c98 Use TOML output for -print-config instead of JSON.
This is much easier to read, and can be used as a template for
a new configuration.
2025-12-07 05:43:00 +00:00

346 lines
11 KiB
Go

package git_pages
import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"os"
"reflect"
"slices"
"strconv"
"strings"
"time"
"github.com/c2h5oh/datasize"
"github.com/creasty/defaults"
"github.com/pelletier/go-toml/v2"
)
// For an unknown reason, the standard `time.Duration` type doesn't implement the standard
// `encoding.{TextMarshaler,TextUnmarshaler}` interfaces.
type Duration time.Duration
func (t Duration) String() string {
return fmt.Sprint(time.Duration(t))
}
func (t *Duration) UnmarshalText(data []byte) (err error) {
u, err := time.ParseDuration(string(data))
if err == nil {
*t = Duration(u)
}
return
}
func (t *Duration) MarshalText() ([]byte, error) {
return []byte(t.String()), nil
}
// For a known but upsetting reason, the standard `url.URL` type doesn't implement the standard
// `encoding.{TextMarshaler,TextUnmarshaler}` interfaces.
type URL struct {
url.URL
}
func (t *URL) String() string {
return fmt.Sprint(&t.URL)
}
func (t *URL) UnmarshalText(data []byte) (err error) {
u, err := url.Parse(string(data))
if err == nil {
*t = URL{*u}
}
return
}
func (t *URL) MarshalText() ([]byte, error) {
return []byte(t.String()), nil
}
type Config struct {
Insecure bool `toml:"-" env:"insecure"`
Features []string `toml:"features"`
LogFormat string `toml:"log-format" default:"text"`
LogLevel string `toml:"log-level" default:"info"`
Server ServerConfig `toml:"server"`
Wildcard []WildcardConfig `toml:"wildcard"`
Fallback FallbackConfig `toml:"fallback"`
Storage StorageConfig `toml:"storage"`
Limits LimitsConfig `toml:"limits"`
Audit AuditConfig `toml:"audit"`
Observability ObservabilityConfig `toml:"observability"`
}
type ServerConfig struct {
Pages string `toml:"pages" default:"tcp/:3000"`
Caddy string `toml:"caddy" default:"tcp/:3001"`
Metrics string `toml:"metrics" default:"tcp/:3002"`
}
type WildcardConfig struct {
Domain string `toml:"domain"`
CloneURL string `toml:"clone-url"` // URL template, not an exact URL
IndexRepos []string `toml:"index-repos" default:"[]"`
IndexRepoBranch string `toml:"index-repo-branch" default:"pages"`
Authorization string `toml:"authorization"`
}
type FallbackConfig struct {
ProxyTo *URL `toml:"proxy-to"`
Insecure bool `toml:"insecure"`
}
type CacheConfig struct {
MaxSize datasize.ByteSize `toml:"max-size"`
MaxAge Duration `toml:"max-age"`
MaxStale Duration `toml:"max-stale"`
}
type StorageConfig struct {
Type string `toml:"type" default:"fs"`
FS FSConfig `toml:"fs" default:"{\"Root\":\"./data\"}"`
S3 S3Config `toml:"s3"`
}
type FSConfig struct {
Root string `toml:"root"`
}
type S3Config struct {
Endpoint string `toml:"endpoint"`
Insecure bool `toml:"insecure"`
AccessKeyID string `toml:"access-key-id"`
SecretAccessKey string `toml:"secret-access-key"`
Region string `toml:"region"`
Bucket string `toml:"bucket"`
BlobCache CacheConfig `toml:"blob-cache" default:"{\"MaxSize\":\"256MB\"}"`
SiteCache CacheConfig `toml:"site-cache" default:"{\"MaxAge\":\"60s\",\"MaxStale\":\"1h\",\"MaxSize\":\"16MB\"}"`
}
type LimitsConfig struct {
// Maximum size of a single published site. Also used to limit the size of archive
// uploads and other similar overconsumption conditions.
MaxSiteSize datasize.ByteSize `toml:"max-site-size" default:"128M"`
// Maximum size of a single site manifest, computed over its binary Protobuf
// serialization.
MaxManifestSize datasize.ByteSize `toml:"max-manifest-size" default:"1M"`
// Maximum size of a file that will still be inlined into the site manifest.
MaxInlineFileSize datasize.ByteSize `toml:"max-inline-file-size" default:"256B"`
// Maximum size of a Git object that will be cached in memory during Git operations.
GitLargeObjectThreshold datasize.ByteSize `toml:"git-large-object-threshold" default:"1M"`
// Maximum number of symbolic link traversals before the path is considered unreachable.
MaxSymlinkDepth uint `toml:"max-symlink-depth" default:"16"`
// Maximum time that an update operation (PUT or POST request) could take before being
// interrupted.
UpdateTimeout Duration `toml:"update-timeout" default:"60s"`
// Soft limit on Go heap size, expressed as a fraction of total available RAM.
MaxHeapSizeRatio float64 `toml:"max-heap-size-ratio" default:"0.5"`
// List of domains unconditionally forbidden for uploads.
ForbiddenDomains []string `toml:"forbidden-domains" default:"[]"`
// List of allowed repository URL prefixes. Setting this option prohibits uploading archives.
AllowedRepositoryURLPrefixes []string `toml:"allowed-repository-url-prefixes"`
// List of allowed custom headers. Header name must be in the MIME canonical form,
// e.g. `Foo-Bar`. Setting this option permits including this custom header in `_headers`,
// unless it is fundamentally unsafe.
AllowedCustomHeaders []string `toml:"allowed-custom-headers" default:"[\"X-Clacks-Overhead\"]"`
}
type AuditConfig struct {
// Globally unique machine identifier (0 to 63 inclusive).
NodeID int `toml:"node-id"`
// Whether audit reports should be stored whenever an audit event occurs.
Collect bool `toml:"collect"`
// If not empty, includes the principal's IP address in audit reports, with the value specifying
// the source of the IP address. If the value is "X-Forwarded-For", the last item of the
// corresponding header field (assumed to be comma-separated) is used. If the value is
// "RemoteAddr", the connecting host's address is used. Any other value is disallowed.
IncludeIPs string `toml:"include-ip"`
// Endpoint to notify with a `GET /<notify-url>?<id>` whenever an audit event occurs.
NotifyURL *URL `toml:"notify-url"`
}
type ObservabilityConfig struct {
// Minimum duration for an HTTP request transaction to be unconditionally sampled.
SlowResponseThreshold Duration `toml:"slow-response-threshold" default:"500ms"`
}
func (config *Config) TOML() string {
result, err := toml.Marshal(config)
if err != nil {
panic(err)
}
return string(result)
}
func (config *Config) Feature(name string) bool {
return slices.Contains(config.Features, name)
}
type walkConfigState struct {
config reflect.Value
scopeType reflect.Type
index []int
segments []string
}
func walkConfigScope(scopeState walkConfigState, onKey func(string, reflect.Value) error) (err error) {
for _, field := range reflect.VisibleFields(scopeState.scopeType) {
fieldState := walkConfigState{config: scopeState.config}
fieldState.scopeType = field.Type
fieldState.index = append(scopeState.index, field.Index...)
var tagValue, ok = "", false
if tagValue, ok = field.Tag.Lookup("env"); !ok {
if tagValue, ok = field.Tag.Lookup("toml"); !ok {
continue // implicit skip
}
} else if tagValue == "-" {
continue // explicit skip
}
fieldSegment := strings.ReplaceAll(strings.ToUpper(tagValue), "-", "_")
fieldState.segments = append(scopeState.segments, fieldSegment)
switch field.Type.Kind() {
case reflect.Struct:
err = walkConfigScope(fieldState, onKey)
default:
err = onKey(
strings.Join(fieldState.segments, "_"),
scopeState.config.FieldByIndex(fieldState.index),
)
}
if err != nil {
return
}
}
return
}
func walkConfig(config *Config, onKey func(string, reflect.Value) error) error {
state := walkConfigState{
config: reflect.ValueOf(config).Elem(),
scopeType: reflect.TypeOf(config).Elem(),
index: []int{},
segments: []string{"PAGES"},
}
return walkConfigScope(state, onKey)
}
func setConfigValue(reflValue reflect.Value, repr string) (err error) {
valueAny := reflValue.Interface()
switch valueCast := valueAny.(type) {
case string:
reflValue.SetString(repr)
case []string:
reflValue.Set(reflect.ValueOf(strings.Split(repr, ",")))
case bool:
if valueCast, err = strconv.ParseBool(repr); err == nil {
reflValue.SetBool(valueCast)
}
case int:
var parsed int64
if parsed, err = strconv.ParseInt(repr, 10, strconv.IntSize); err == nil {
reflValue.SetInt(parsed)
}
case uint:
var parsed uint64
if parsed, err = strconv.ParseUint(repr, 10, strconv.IntSize); err == nil {
reflValue.SetUint(parsed)
}
case float64:
if valueCast, err = strconv.ParseFloat(repr, 64); err == nil {
reflValue.SetFloat(valueCast)
}
case datasize.ByteSize:
if valueCast, err = datasize.ParseString(repr); err == nil {
reflValue.Set(reflect.ValueOf(valueCast))
}
case Duration:
var parsed time.Duration
if parsed, err = time.ParseDuration(repr); err == nil {
reflValue.Set(reflect.ValueOf(Duration(parsed)))
}
case *URL:
if repr == "" {
reflValue.Set(reflect.ValueOf(nil))
} else {
var parsed *url.URL
if parsed, err = url.Parse(repr); err == nil {
reflValue.Set(reflect.ValueOf(&URL{*parsed}))
}
}
case []WildcardConfig:
var parsed []*WildcardConfig
decoder := json.NewDecoder(bytes.NewReader([]byte(repr)))
decoder.DisallowUnknownFields()
if err = decoder.Decode(&parsed); err == nil {
var assigned []WildcardConfig
for _, wildcard := range parsed {
defaults.MustSet(wildcard)
assigned = append(assigned, *wildcard)
}
reflValue.Set(reflect.ValueOf(assigned))
}
default:
panic("unhandled config value type")
}
return err
}
func PrintConfigEnvVars() {
config := Config{}
defaults.MustSet(&config)
walkConfig(&config, func(envName string, reflValue reflect.Value) (err error) {
value := reflValue.Interface()
reprBefore := fmt.Sprint(value)
fmt.Printf("%s %T = %q\n", envName, value, reprBefore)
// make sure that the value, at least, roundtrips
setConfigValue(reflValue, reprBefore)
reprAfter := fmt.Sprint(value)
if reprBefore != reprAfter {
panic("failed to roundtrip config value")
}
return
})
}
func Configure(tomlPath string) (config *Config, err error) {
// start with an all-default configuration
config = new(Config)
defaults.MustSet(config)
// inject values from `config.toml`
if tomlPath != "" {
var file *os.File
file, err = os.Open(tomlPath)
if err != nil {
return
}
defer file.Close()
decoder := toml.NewDecoder(file)
decoder.DisallowUnknownFields()
decoder.EnableUnmarshalerInterface()
if err = decoder.Decode(&config); err != nil {
return
}
}
// inject values from the environment, overriding everything else
err = walkConfig(config, func(envName string, reflValue reflect.Value) error {
if envValue, found := os.LookupEnv(envName); found {
return setConfigValue(reflValue, envValue)
}
return nil
})
// defaults for wildcards aren't set by `defaults.MustSet` call above because the structs
// for them haven't been created yet
for i := range config.Wildcard {
defaults.MustSet(&config.Wildcard[i])
}
return
}