Download
504 lines 18.8 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
// Package config loads and validates repo-render.yaml.
package config

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"maps"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"strings"
	"time"
	"unicode"

	"github.com/goccy/go-yaml"
)

// PrivateDir is the directory of private_htdocs that the private site is
// written to, and the URL path it is served at, password protected.
const PrivateDir = "private"

// PrivatePrefix is the URL path the private site is served under.
const PrivatePrefix = "/" + PrivateDir

// LogSyslog as the log setting logs to syslog(3), as daemon.
const LogSyslog = "syslog"

// RepoGitConfig is the git configuration of a repository the site
// renders: branches cannot be deleted (but can be force-pushed).
var RepoGitConfig = [][2]string{
	{"receive.denyDeletes", "true"},
	{"receive.denyNonFastForwards", "false"},
}

// Config is the parsed repo-render.yaml. Zero values are filled by
// Defaults. The renderer knows no paths of its own: git_dir, htdocs,
// spool_dir and state_dir are required.
type Config struct {
	Host   string `yaml:"host"`
	GitDir string `yaml:"git_dir"`
	Htdocs string `yaml:"htdocs"`
	// PrivateHtdocs is where private repositories are rendered, to be
	// served at /private/ behind a password; "" renders them nowhere. It
	// must be apart from Htdocs, so that a web server serving Htdocs
	// never serves it.
	PrivateHtdocs string   `yaml:"private_htdocs"`
	SpoolDir      string   `yaml:"spool_dir"`
	StateDir      string   `yaml:"state_dir"`
	Log           string   `yaml:"log"` // LogSyslog, a file, or "" for stderr only
	LexerDirs     []string `yaml:"lexer_dirs"`
	// Git is the git program: a path, or a name looked up on PATH; ""
	// looks for git on PATH.
	Git               string   `yaml:"git"`
	Workers           int      `yaml:"workers"`
	HighlightMaxBytes int64    `yaml:"highlight_max_bytes"`
	MarkdownMaxBytes  int64    `yaml:"markdown_max_bytes"`
	RawMaxBytes       int64    `yaml:"raw_max_bytes"`
	SearchMaxBytes    int64    `yaml:"search_max_bytes"`
	SearchExclude     []string `yaml:"search_exclude"`
	MaxResults        int      `yaml:"max_results"`
	ListingTruncate   int      `yaml:"listing_truncate"`
	// RepoMaxBytes caps the total size of the files in a tree the
	// renderer writes out, a branch's or a tag's; 0 means no cap. Each
	// file has its own caps above, but a commit of many files under them
	// could still fill the disk the whole site lives on.
	RepoMaxBytes int64 `yaml:"repo_max_bytes"`
	// RepoMaxFiles caps the number of entries in such a tree; 0 means no
	// cap. Each file costs several files of output (its page, the page's
	// gzip twin, its raw copy), so many tiny files could use up the
	// disk's inodes while staying far under RepoMaxBytes.
	RepoMaxFiles int `yaml:"repo_max_files"`
	// ArchiveRetention is how long a branch-tip tarball stays after a
	// newer one replaces it, so pages and frames cached before the push
	// still link to a file that exists.
	ArchiveRetention time.Duration `yaml:"archive_retention"`
	Style            string        `yaml:"style"`
	// StyleLight is the chroma style used when the page is in light mode.
	StyleLight string `yaml:"style_light"`

	Site     Site             `yaml:"site"`
	Sections []Section        `yaml:"sections"`
	Lexers   map[string]Lexer `yaml:"lexers"`
	Repos    map[string]Repo  `yaml:"repos"`

	// Source is the raw file content; it feeds the template hash so a config
	// change forces a full re-render.
	Source []byte `yaml:"-"`
	// Path the config was loaded from ("" when defaults only).
	Path string `yaml:"-"`

	// Listen, Aliases, Htpasswd and SSHKey are no longer read: they are
	// for the web server and mirroring, which the renderer does not do.
	// The fields stay so that Validate can say where they went, not just
	// "unknown field".
	Listen   string   `yaml:"listen"`
	Aliases  []string `yaml:"aliases"`
	Htpasswd string   `yaml:"htpasswd"`
	SSHKey   string   `yaml:"ssh_key"`
}

// Site controls the presentation of the whole site: titles, footer, and how
// the repository index is organised.
type Site struct {
	// Title is shown as the site heading on every page and in <title>.
	Title string `yaml:"title"`
	// Tagline is optional text shown under the title on the index page.
	Tagline string `yaml:"tagline"`
	// Footer is optional HTML placed at the bottom of every page. It is
	// operator-controlled and inserted verbatim.
	Footer string `yaml:"footer"`
	// URL is where the site is served, used for absolute links such as the
	// link-preview tags. Default: https://<host>
	URL string `yaml:"url"`
	// CloneURL is a template for the clone instructions; "{repo}" is replaced
	// by the repository name. Default: <url>/git/{repo}.git
	CloneURL string `yaml:"clone_url"`
	// Favicon is a path to an .svg, .png, .ico, .gif or .webp file that
	// replaces the built-in icon. A relative path is relative to the config
	// file.
	Favicon string `yaml:"favicon"`
	// Unlisted controls repositories that appear in no section:
	// "private" (default) keeps them off the public site: they are
	// rendered only to the password-protected private site, or nowhere
	// when there is none (PrivateHtdocs), and output an earlier render
	// left on the public site is removed. "show" puts them in a trailing section of the index, and
	// "hide" leaves them off the index but still renders them, so they
	// are reachable by URL.
	Unlisted string `yaml:"unlisted"`
	// UnlistedTitle is the heading of that trailing section.
	UnlistedTitle string `yaml:"unlisted_title"`
}

// Section is one group of repositories on the index page. Title is inline
// markdown and Description is markdown.
type Section struct {
	Title       string   `yaml:"title"`
	Description string   `yaml:"description"`
	Repos       []string `yaml:"repos"`
}

// Lexer maps file globs and fence names to a chroma lexer. The map key is the
// lexer name: a custom XML lexer (<name>.xml) in the first of LexerDirs that
// has one, or a chroma builtin.
type Lexer struct {
	Globs   []string `yaml:"globs"`
	Aliases []string `yaml:"aliases"`
}

// Repo holds per-repository overrides.
type Repo struct {
	// Mirrors is no longer read: the post-receive hook pushes to the
	// mirrors in the repository's own git config. The field stays so that
	// Validate can say where to move it.
	Mirrors []string `yaml:"mirrors"`
	// Description is no longer read: a repository's description is its
	// git/<repo>.git/description file alone. The field stays so that
	// Validate can say where to move it, not just "unknown field".
	Description string `yaml:"description"`
	// Branch overrides the rendered branch (default: the bare repo's HEAD).
	Branch string `yaml:"branch"`
	// Branches is an explicit allowlist of additional branches to render
	// beside the default one, each under /code/<repo>@<branch>/ with "/" in
	// the name written as ":". Exact names only; a listed branch that does
	// not exist is skipped until it does.
	Branches []string `yaml:"branches"`
	// Tags are short labels, like "lua" or "language", shown beside the
	// repository on the index and on its page. They are only informational.
	Tags []string `yaml:"tags"`
}

// Defaults returns a Config with every default filled in.
func Defaults() *Config {
	return &Config{
		HighlightMaxBytes: 1 << 20,
		MarkdownMaxBytes:  512 << 10,
		RawMaxBytes:       32 << 20,
		SearchMaxBytes:    1 << 20,
		RepoMaxBytes:      1 << 30,
		RepoMaxFiles:      25_000,
		SearchExclude:     []string{"*.min.js", "*.lock", "*-lock.json", "*.svg"},
		MaxResults:        2000,
		ListingTruncate:   5,
		ArchiveRetention:  time.Hour,
		Style:             "tokyonight-night",
		StyleLight:        "github",
		Site: Site{
			Title:         "Code",
			Unlisted:      "private",
			UnlistedTitle: "Other repositories",
		},
		Lexers: map[string]Lexer{},
		Repos:  map[string]Repo{},
	}
}

// Load reads and validates a repo-render.yaml. Unknown keys are errors.
// Relative paths in the file are relative to the file's directory, so a
// config can keep everything beside it (handy for development and tests).
func Load(path string) (*Config, error) {
	src, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	cfg, err := Parse(src)
	if err != nil {
		return nil, fmt.Errorf("%s: %w", path, err)
	}
	if path, err = filepath.Abs(path); err != nil {
		return nil, err
	}
	cfg.Path = path
	base := filepath.Dir(path)
	for _, p := range []*string{&cfg.GitDir, &cfg.Htdocs, &cfg.PrivateHtdocs, &cfg.SpoolDir, &cfg.StateDir, &cfg.Log, &cfg.Site.Favicon} {
		if *p != "" && *p != LogSyslog && !filepath.IsAbs(*p) {
			*p = filepath.Join(base, *p)
		}
	}
	// A bare name is looked up on PATH; a relative path is relative to
	// this file, as the other paths are.
	if g := cfg.Git; strings.ContainsRune(g, '/') && !filepath.IsAbs(g) {
		cfg.Git = filepath.Join(base, g)
	}
	for i, d := range cfg.LexerDirs {
		if !filepath.IsAbs(d) {
			cfg.LexerDirs[i] = filepath.Join(base, d)
		}
	}
	if err := cfg.checkPrivateHtdocs(); err != nil {
		return nil, fmt.Errorf("%s: %w", path, err)
	}
	return cfg, nil
}

// Parse decodes config text on top of Defaults and validates it.
func Parse(src []byte) (*Config, error) {
	cfg := Defaults()
	err := yaml.NewDecoder(bytes.NewReader(src), yaml.DisallowUnknownField()).Decode(cfg)
	if errors.Is(err, io.EOF) {
		// A file with no document (empty, or only comments) sets nothing,
		// but the decoder has already zeroed cfg.
		cfg = Defaults()
	} else if err != nil {
		return nil, err
	}
	cfg.Source = src
	if err := cfg.Validate(); err != nil {
		return nil, err
	}
	if err := cfg.checkPrivateHtdocs(); err != nil {
		return nil, err
	}
	return cfg, nil
}

// checkPrivateHtdocs keeps the private site out of the public one, where
// the web server would serve it to anyone, and the public one out of it.
// It runs once on parsing, and again once Load has made the paths
// absolute.
func (c *Config) checkPrivateHtdocs() error {
	if c.PrivateHtdocs == "" {
		return nil
	}
	pub, priv := filepath.Clean(c.Htdocs), filepath.Clean(c.PrivateHtdocs)
	if within(priv, pub) || within(pub, priv) {
		return fmt.Errorf("private_htdocs %s must be outside htdocs %s, and htdocs outside it", c.PrivateHtdocs, c.Htdocs)
	}
	return nil
}

// within reports whether p is dir or under it; both are clean.
func within(p, dir string) bool {
	rel, err := filepath.Rel(dir, p)
	return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

var repoNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)

// ValidRepoName reports whether name is safe to use as a repository name in
// paths and URLs.
func ValidRepoName(name string) bool {
	return repoNameRE.MatchString(name) && !strings.HasSuffix(name, ".git") && name != "." && name != ".."
}

var branchPartRE = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*$`)

// ValidBranchName reports whether name can be rendered as a branch view: a
// plain git branch name restricted to letters, digits and . _ - with "/"
// between components. The restriction keeps URLs and file names free of
// escaping; ":" (the slash's stand-in in URLs) never occurs in git ref
// names, so BranchSlug is reversible. git reserves "HEAD".
func ValidBranchName(name string) bool {
	if name == "HEAD" {
		return false
	}
	for part := range strings.SplitSeq(name, "/") {
		if !branchPartRE.MatchString(part) || strings.HasSuffix(part, ".lock") || strings.HasSuffix(part, ".") || strings.Contains(part, "..") {
			return false
		}
	}
	return true
}

// BranchSlug is a branch name as it appears in URLs and file names.
func BranchSlug(branch string) string { return strings.ReplaceAll(branch, "/", ":") }

// ViewKey names the output namespace of a branch view: the repository name
// itself for the default branch (branch == ""), else "<repo>@<slug>". Repo
// names cannot contain "@", so keys never collide.
func ViewKey(repo, branch string) string {
	if branch == "" {
		return repo
	}
	return repo + "@" + BranchSlug(branch)
}

// hostRE admits DNS-style names (and "localhost"): letters, digits, dots and
// hyphens, no leading dot, so the name is a single safe path component.
var hostRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)*$`)

// ValidHost reports whether h can be the config's host.
func ValidHost(h string) bool { return hostRE.MatchString(h) }

// Validate checks cross-field constraints.
func (c *Config) Validate() error {
	var errs []error
	if c.Host == "" {
		errs = append(errs, errors.New("host is required"))
	} else if !ValidHost(c.Host) {
		errs = append(errs, fmt.Errorf("host must be a hostname, got %q", c.Host))
	}
	for name, set := range map[string]bool{"listen": c.Listen != "", "aliases": c.Aliases != nil, "htpasswd": c.Htpasswd != ""} {
		if set {
			errs = append(errs, fmt.Errorf("%s: the renderer no longer configures the web server; remove it here (it goes in the web server's config: see examples/httpd.conf)", name))
		}
	}
	if c.SSHKey != "" {
		errs = append(errs, fmt.Errorf("ssh_key: the renderer no longer mirrors; remove it here, and set each repository's core.sshCommand to use the key (see contrib/new-repo)"))
	}
	if u := c.Site.URL; u != "" && !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
		errs = append(errs, fmt.Errorf("site.url must start with http:// or https://, got %q", u))
	}
	for name, v := range map[string]string{
		"git_dir": c.GitDir, "htdocs": c.Htdocs, "spool_dir": c.SpoolDir, "state_dir": c.StateDir,
	} {
		if v == "" {
			errs = append(errs, fmt.Errorf("%s is required", name))
		}
	}
	if c.SpoolDir != "" && filepath.Clean(c.SpoolDir) == filepath.Clean(c.StateDir) {
		// The queue's files come and go as jobs are taken; the state and
		// the lock stay apart from them.
		errs = append(errs, errors.New("spool_dir and state_dir must be different directories"))
	}
	for name, v := range map[string]int64{
		"highlight_max_bytes": c.HighlightMaxBytes, "markdown_max_bytes": c.MarkdownMaxBytes,
		"raw_max_bytes": c.RawMaxBytes, "search_max_bytes": c.SearchMaxBytes,
		"repo_max_bytes": c.RepoMaxBytes,
	} {
		if v < 0 {
			errs = append(errs, fmt.Errorf("%s must be >= 0", name))
		}
	}
	if c.RepoMaxFiles < 0 {
		errs = append(errs, errors.New("repo_max_files must be >= 0"))
	}
	if c.Workers < 0 {
		errs = append(errs, errors.New("workers must be >= 0"))
	}
	if c.ListingTruncate < 1 {
		errs = append(errs, errors.New("listing_truncate must be >= 1"))
	}
	if c.ArchiveRetention < 0 {
		errs = append(errs, errors.New("archive_retention must be >= 0"))
	}
	if c.MaxResults < 1 {
		errs = append(errs, errors.New("max_results must be >= 1"))
	}
	if f := c.Site.Favicon; f != "" {
		switch strings.ToLower(filepath.Ext(f)) {
		case ".svg", ".png", ".ico", ".gif", ".webp":
		default:
			errs = append(errs, fmt.Errorf("site.favicon must be .svg, .png, .ico, .gif or .webp, got %q", f))
		}
	}
	switch c.Site.Unlisted {
	case "private", "show", "hide":
	default:
		errs = append(errs, fmt.Errorf("site.unlisted must be \"private\", \"show\" or \"hide\", got %q", c.Site.Unlisted))
	}
	for _, g := range c.SearchExclude {
		if _, err := filepath.Match(g, "x"); err != nil {
			errs = append(errs, fmt.Errorf("search_exclude: bad glob %q", g))
		}
	}
	for name, lx := range c.Lexers {
		for _, g := range lx.Globs {
			if _, err := filepath.Match(g, "x"); err != nil {
				errs = append(errs, fmt.Errorf("lexers.%s: bad glob %q", name, g))
			}
		}
	}
	seen := map[string]int{}
	for i, s := range c.Sections {
		if s.Title == "" {
			errs = append(errs, fmt.Errorf("sections[%d]: title is required", i))
		}
		for _, r := range s.Repos {
			if !ValidRepoName(r) {
				errs = append(errs, fmt.Errorf("sections[%d]: invalid repo name %q", i, r))
			}
			if j, dup := seen[r]; dup {
				errs = append(errs, fmt.Errorf("repo %q listed in both sections[%d] and sections[%d]", r, j, i))
			}
			seen[r] = i
		}
	}
	for r, rc := range c.Repos {
		if !ValidRepoName(r) {
			errs = append(errs, fmt.Errorf("repos: invalid repo name %q", r))
		}
		// The default branch names files in the clone export and is written
		// into its HEAD, so it is held to what new-repo accepts.
		if b := rc.Branch; b != "" && !ValidBranchName(b) {
			errs = append(errs, fmt.Errorf("repos.%s.branch: %q is not a usable branch name (letters, digits, . _ - and / between components)", r, b))
		}
		if rc.Description != "" {
			errs = append(errs, fmt.Errorf("repos.%s.description: descriptions now come only from the repository's description file; move it to %s and remove it here",
				r, filepath.Join(c.GitDir, r+".git", "description")))
		}
		if len(rc.Mirrors) > 0 {
			errs = append(errs, fmt.Errorf("repos.%s.mirrors: the renderer no longer mirrors; remove them here, and add each to the repository's git config, which the post-receive hook pushes to: git -C %s config --add repo-render.mirror URL",
				r, filepath.Join(c.GitDir, r+".git")))
		}
		dup := map[string]bool{}
		for _, b := range rc.Branches {
			switch {
			case !ValidBranchName(b):
				errs = append(errs, fmt.Errorf("repos.%s.branches: %q is not a usable branch name (letters, digits, . _ - and / between components)", r, b))
			case dup[b]:
				errs = append(errs, fmt.Errorf("repos.%s.branches: %q listed twice", r, b))
			}
			dup[b] = true
		}
		tags := map[string]bool{}
		for _, tag := range rc.Tags {
			switch {
			case strings.TrimSpace(tag) != tag || tag == "" || strings.ContainsFunc(tag, unicode.IsControl):
				errs = append(errs, fmt.Errorf("repos.%s.tags: %q is not a usable tag (not empty, no control characters or surrounding spaces)", r, tag))
			case tags[tag]:
				errs = append(errs, fmt.Errorf("repos.%s.tags: %q listed twice", r, tag))
			}
			tags[tag] = true
		}
	}
	return errors.Join(errs...)
}

// BaseURL is the site's public URL, without a trailing slash.
func (c *Config) BaseURL() string {
	if c.Site.URL != "" {
		return strings.TrimRight(c.Site.URL, "/")
	}
	return "https://" + c.Host
}

// CloneURL returns the clone URL shown for repo.
func (c *Config) CloneURL(repo string) string {
	tmpl := c.Site.CloneURL
	if tmpl == "" {
		tmpl = c.BaseURL() + "/git/{repo}.git"
	}
	return strings.ReplaceAll(tmpl, "{repo}", repo)
}

// PrivateCloneURL is the clone URL shown for a private repository: its
// export on the private site.
func (c *Config) PrivateCloneURL(repo string) string {
	return c.BaseURL() + "/" + PrivateDir + "/git/" + repo + ".git"
}

// Repo returns the per-repo overrides (zero value when absent).
func (c *Config) Repo(name string) Repo {
	return c.Repos[name]
}

// Private reports whether repo is kept off the public site: it is in no
// section and site.unlisted is "private". It is on the private site, when
// there is one (PrivateHtdocs).
func (c *Config) Private(repo string) bool {
	return c.Site.Unlisted == "private" && c.SectionOf(repo) < 0
}

// SectionOf returns the index of the section listing repo, or -1.
func (c *Config) SectionOf(repo string) int {
	return slices.IndexFunc(c.Sections, func(s Section) bool { return slices.Contains(s.Repos, repo) })
}

// LexerNames returns configured lexer names in a stable order.
func (c *Config) LexerNames() []string {
	return slices.Sorted(maps.Keys(c.Lexers))
}