mirror of
https://codeberg.org/git-pages/git-pages.git
synced 2026-05-14 03:01:48 +00:00
346 lines
11 KiB
Go
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
|
|
}
|