vitrine

config.go

package main

import (
	"fmt"
	"net/url"
	"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"`
	// Raw makes files serve as their raw bytes by default, as if every request
	// carried ?fmt=raw: no rendered-markdown view, no iframe frame.
	// Directories still render the normal listing.
	Raw bool `yaml:"raw"`
}

// forward is one configured reverse-proxy mount:
// requests under a URL prefix are forwarded to another http service.
type forward struct {
	// To is the target base URL: scheme://host[:port][/path], scheme http or https.
	// Websocket backends also use http/https: a websocket connection begins life
	// as an ordinary http request (with an Upgrade header), so there is no ws:// target.
	To string `yaml:"to"`
	// url is To, parsed; loadConfig guarantees it for every forward it returns.
	url *url.URL
}

// 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"`
	// Forwards maps a URL prefix to a service proxied behind it.
	// The name "/" is the root forward: it catches every request
	// that no exhibit, forward, or synthetic listing claims.
	Forwards map[string]forward `yaml:"forwards"`
}

// 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 && len(cfg.Forwards) == 0 {
		return nil, fmt.Errorf("config %s: no exhibits or forwards 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

	forwards := make(map[string]forward, len(cfg.Forwards))
	for name, f := range cfg.Forwards {
		// Forward names are URL prefixes; leading and trailing slashes are
		// accepted and normalized away.  Unlike exhibits, the all-slashes name is
		// meaningful here: "/" normalizes to "" and denotes the root forward.
		cleanName := strings.Trim(name, "/")
		if _, dup := forwards[cleanName]; dup {
			return nil, fmt.Errorf("config %s: forward %q: duplicate of %q after trimming slashes", path, name, cleanName)
		}
		if _, clash := normalized[cleanName]; clash {
			return nil, fmt.Errorf("config %s: forward %q: collides with the exhibit of the same name", path, name)
		}
		u, err := parseForwardTarget(f.To)
		if err != nil {
			return nil, fmt.Errorf("config %s: forward %q: %w", path, name, err)
		}
		f.url = u
		forwards[cleanName] = f
	}
	cfg.Forwards = forwards
	return &cfg, nil
}

// parseForwardTarget validates a forward's "to" URL.
// The supported shape is exactly scheme://host[:port][/path]:
// userinfo, query, and fragment have no meaning for a forward target
// and are rejected rather than silently merged into every proxied request.
func parseForwardTarget(raw string) (*url.URL, error) {
	if raw == "" {
		return nil, fmt.Errorf("missing required field %q", "to")
	}
	u, err := url.Parse(raw)
	if err != nil {
		return nil, fmt.Errorf("target %q: %w", raw, err)
	}
	switch u.Scheme {
	case "http", "https":
	case "ws", "wss":
		return nil, fmt.Errorf("target %q: websockets ride ordinary http (the handshake is an http request with an Upgrade header), so the target scheme is http:// or https:// even for pure-websocket backends", raw)
	default:
		// This branch also catches bare host:port, which url.Parse reads as
		// scheme host, opaque port -- hence scheme-mandatory, no sugar.
		return nil, fmt.Errorf("target %q: scheme must be http:// or https:// (and is mandatory; bare host:port is not accepted)", raw)
	}
	if u.Host == "" {
		return nil, fmt.Errorf("target %q: missing host", raw)
	}
	if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
		return nil, fmt.Errorf("target %q: userinfo, query, and fragment are not supported in a forward target", raw)
	}
	return u, nil
}

config.go · 6.6 KiB · 8027399b52