config.go
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// exhibit is one configured mount: a directory served under a URL prefix.
type exhibit struct {
// Path is the directory to serve.
// In the config file it may be relative (resolved against the config file's directory),
// or omitted entirely, which means "same as the exhibit name";
// after loadConfig it is always absolute.
Path string `yaml:"path"`
// ExposeGit makes the .git directory list and serve like any other content
// (it is hidden and unreachable by default).
ExposeGit bool `yaml:"expose-git"`
}
// config is the top-level schema of the vitrine config file.
type config struct {
// Exhibits maps a URL prefix (which may contain slashes) to what it serves.
Exhibits map[string]exhibit `yaml:"exhibits"`
}
// loadConfig reads and validates the YAML config file at the given path.
// Relative exhibit paths are resolved against the config file's directory,
// so a config travels with the tree it describes.
// Any parse or validation problem is an error: vitrine refuses to start on a bad config.
func loadConfig(path string) (*config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("config: %w", err)
}
defer f.Close()
var cfg config
dec := yaml.NewDecoder(f)
dec.KnownFields(true) // typo'd keys are errors, not silence
if err := dec.Decode(&cfg); err != nil {
return nil, fmt.Errorf("config %s: %w", path, err)
}
if len(cfg.Exhibits) == 0 {
return nil, fmt.Errorf("config %s: no exhibits configured; a vitrine with nothing on display is a misconfiguration", path)
}
base, err := filepath.Abs(filepath.Dir(path))
if err != nil {
return nil, fmt.Errorf("config %s: resolving directory: %w", path, err)
}
normalized := make(map[string]exhibit, len(cfg.Exhibits))
for name, ex := range cfg.Exhibits {
// Trailing slashes on names and paths are accepted and normalized away.
// A slash-suffixed name would otherwise silently break dispatch
// (the listing works but every file inside 404s),
// and a slash-suffixed path is harmless but inconsistent.
// Directory-ness is not lost as a check: openRoot fails on non-directories anyway.
cleanName := strings.TrimRight(name, "/")
if cleanName == "" {
return nil, fmt.Errorf("config %s: exhibit %q: name is empty after trimming slashes", path, name)
}
if _, dup := normalized[cleanName]; dup {
return nil, fmt.Errorf("config %s: exhibit %q: duplicate of %q after trimming slashes", path, name, cleanName)
}
// The path "%" is a placeholder meaning "same as the exhibit name":
// the near-universal case where the URL prefix mirrors the directory layout
// relative to the config file.
// (Explicit placeholder rather than an omittable field:
// a bare key with no value reads like an accident in yaml.)
if ex.Path == "%" {
ex.Path = cleanName
}
if ex.Path == "" {
return nil, fmt.Errorf("config %s: exhibit %q: missing required field %q (use %q to serve the path matching the exhibit name)", path, name, "path", "path: %")
}
ex.Path = strings.TrimRight(ex.Path, "/")
if ex.Path == "" {
// Only "/" (or "///"...) trims to nothing: serving the root filesystem
// is a misconfiguration, not a wish we should guess at.
return nil, fmt.Errorf("config %s: exhibit %q: path %q is the filesystem root; refusing", path, name, "/")
}
if !filepath.IsAbs(ex.Path) {
ex.Path = filepath.Join(base, ex.Path)
}
normalized[cleanName] = ex
}
cfg.Exhibits = normalized
return &cfg, nil
}
config.go · 3.5 KiB · bd3b610b14