forked from
whitequark.org/git-pages
fork of whitequark.org/git-pages with mods for tangled
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}