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