socle

config.go

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"

	"gopkg.in/yaml.v3"
)

// repoConfig is one configured repository: a directory served under a URL name.
type repoConfig struct {
	// Path is the repository directory: a worktree checkout (with a .git inside) or a bare repo,
	// either is fine.  In the config file it may be relative (resolved against the config file's
	// directory) or the placeholder "%" (meaning: same as the repo name);
	// after loadConfig it is always absolute.
	Path string `yaml:"path"`
	// Description is an optional one-liner shown in listings.
	Description string `yaml:"description"`
}

// config is the top-level schema of the socle config file.
type config struct {
	// Sitename is the display identity of this socle: it appears in the header,
	// page titles, the entrance masthead, the footer, and it seeds the theme color.
	// Free-form text; optional.  When empty the identity derives from the baseurl's
	// host label when one is configured, else from the machine hostname --
	// but a published socle usually wants to say its own name, not its machine's.
	Sitename string `yaml:"sitename"`
	// Hue picks the accent's place on the color wheel, 0..359 (0 red, 120 green,
	// 240 blue), keeping the stock light/dark lightness tuning and its contrast
	// guarantees.  When unset, the hue hashes from the theme seed -- a nice
	// diversifier across hosts, but it cannot be argued with, and some names
	// draw ugly.  For exact colors instead, see Accent; setting both is an error.
	Hue *int `yaml:"hue"`
	// Accent takes the wheel entirely: explicit accent color(s), bypassing all
	// tuning -- whoever picks exact colors owns their contrast.  Either one CSS
	// color for both schemes, or a {light, dark} pair:
	//
	//   accent: "#7a5af5"
	//   accent: { light: "hsl(260 60% 35%)", dark: "hsl(260 70% 75%)" }
	//
	// The favicon wears the light color (or the single one).
	Accent *accentConfig `yaml:"accent"`
	// BaseURL is the canonical outside address of this socle, e.g. "https://code.example":
	// scheme and host only, since socle's URL space is rooted at "/".
	// When set, it becomes the displayed clone URL and enables the go-get vanity-import
	// answers (see serveGoGetMeta).  When empty, clone URLs derive from each request
	// and go-get is deliberately not served: socle should never claim an import path
	// for a domain nobody chose.
	BaseURL string `yaml:"baseurl"`
	// Repos maps URL names (which may contain slashes) to the repositories they serve.
	Repos map[string]repoConfig `yaml:"repos"`
}

// accentConfig is an explicit accent: one CSS color per scheme.
// In YAML it may be a bare string (one color, both schemes) or a {light, dark} map.
type accentConfig struct {
	Light string `yaml:"light"`
	Dark  string `yaml:"dark"`
}

func (a *accentConfig) UnmarshalYAML(value *yaml.Node) error {
	if value.Kind == yaml.ScalarNode {
		var one string
		if err := value.Decode(&one); err != nil {
			return err
		}
		a.Light, a.Dark = one, one
		return nil
	}
	// A distinct local type sidesteps UnmarshalYAML recursion on the map form.
	type plain accentConfig
	var p plain
	if err := value.Decode(&p); err != nil {
		return err
	}
	*a = accentConfig(p)
	return nil
}

// validAccentColor keeps config colors from breaking the stylesheet they land in:
// a conservative charset covering hex, rgb()/hsl()/oklch(), and named colors --
// and nothing that can terminate a declaration or smuggle one in.
// (The config is operator-trusted; this guards against typos, not attackers.)
var validAccentColor = regexp.MustCompile(`^[A-Za-z0-9#%(),./ -]+$`)

// loadConfig reads and validates the YAML config file at the given path.
// Relative repo 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: socle refuses to start on a bad config.
// (Whether the configured directories actually exist is deliberately not checked here;
// that's openAll's business, and absence there is a warning, not an error.)
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)
	}

	cfg.Sitename = strings.TrimSpace(cfg.Sitename)
	if cfg.Hue != nil && (*cfg.Hue < 0 || *cfg.Hue > 359) {
		// No modulo forgiveness: 3600 is a typo, not a wish.
		return nil, fmt.Errorf("config %s: hue %d is not on the wheel (0..359)", path, *cfg.Hue)
	}
	if cfg.Hue != nil && cfg.Accent != nil {
		return nil, fmt.Errorf("config %s: hue and accent are both set; pick one (hue keeps the stock tuning, accent takes the wheel)", path)
	}
	if cfg.Accent != nil {
		for name, c := range map[string]string{"light": cfg.Accent.Light, "dark": cfg.Accent.Dark} {
			if strings.TrimSpace(c) == "" {
				return nil, fmt.Errorf("config %s: accent is missing its %q color (a bare string sets both schemes; a map needs both)", path, name)
			}
			if !validAccentColor.MatchString(c) {
				return nil, fmt.Errorf("config %s: accent %s color %q contains characters that have no business in a CSS color", path, name, c)
			}
		}
	}
	if cfg.BaseURL != "" {
		normalized, err := validateBaseURL(cfg.BaseURL)
		if err != nil {
			return nil, fmt.Errorf("config %s: %w", path, err)
		}
		cfg.BaseURL = normalized
	}
	if len(cfg.Repos) == 0 {
		return nil, fmt.Errorf("config %s: no repos configured; a socle 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)
	}
	names := sortedNames(cfg.Repos)
	for _, name := range names {
		rc := cfg.Repos[name]
		if err := validateRepoName(name); err != nil {
			return nil, fmt.Errorf("config %s: %w", path, err)
		}
		// The path "%" is a placeholder meaning "same as the repo name":
		// the near-universal case where the URL layout 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 rc.Path == "%" {
			rc.Path = name
		}
		if rc.Path == "" {
			return nil, fmt.Errorf("config %s: repo %q: missing required field %q (use %q to serve the path matching the repo name)", path, name, "path", "path: %")
		}
		rc.Path = strings.TrimRight(rc.Path, "/")
		if rc.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: repo %q: path %q is the filesystem root; refusing", path, name, "/")
		}
		if !filepath.IsAbs(rc.Path) {
			rc.Path = filepath.Join(base, rc.Path)
		}
		cfg.Repos[name] = rc
	}
	// No repo name may be a path-prefix of another: URL dispatch does longest-prefix
	// matching against these names, and nesting would make "foo/bar" ambiguous between
	// the repo "foo/bar" and the path "bar" within the repo "foo".
	for _, a := range names {
		for _, b := range names {
			if a != b && strings.HasPrefix(b, a+"/") {
				return nil, fmt.Errorf("config %s: repo %q is a path-prefix of repo %q; nested names would make URL dispatch ambiguous", path, a, b)
			}
		}
	}
	return &cfg, nil
}

// sortedNames returns the repo names in byte order:
// every iteration in this codebase happens in this order, so output and errors are deterministic.
func sortedNames[V any](m map[string]V) []string {
	names := make([]string, 0, len(m))
	for name := range m {
		names = append(names, name)
	}
	sort.Strings(names)
	return names
}

config.go · 7.6 KiB · 58cb21c98b