Download
424 lines 15.1 KiBGoModified 2026-10-28 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
// 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"},
}

// DefaultPath is where the config is when neither --config nor
// $REPO_RENDER_CONFIG says.
const DefaultPath = "/etc/repo-render/repo-render.yaml"

// Config is the parsed repo-render.yaml. Zero values are filled by
// Defaults. The renderer knows no paths of its own: git_dir, htdocs 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"`
	// StateDir holds the renderer's saved state, its lock, and the job
	// queue that the hook adds to.
	StateDir  string   `yaml:"state_dir"`
	LexerDirs []string `yaml:"lexer_dirs"`
	Style     string   `yaml:"style"`
	// StyleLight is the chroma style used when the page is in light mode.
	StyleLight string `yaml:"style_light"`

	// The rest are fixed, by Defaults, and not read from the file. Tests
	// change them.

	// Log is LogSyslog, or "" for stderr only.
	Log               string   `yaml:"-"`
	Workers           int      `yaml:"-"` // render jobs at once; 0 means 4 per CPU
	HighlightMaxBytes int64    `yaml:"-"` // larger files get no highlighted page, only a raw link
	MarkdownMaxBytes  int64    `yaml:"-"` // larger Markdown is shown as source only
	RawMaxBytes       int64    `yaml:"-"` // larger files get no raw copy
	SearchMaxBytes    int64    `yaml:"-"` // larger files are left out of search
	SearchExclude     []string `yaml:"-"` // and these, by name
	MaxResults        int      `yaml:"-"` // the most search results shown
	ListingTruncate   int      `yaml:"-"` // entries shown above a README before the rest are folded
	// 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:"-"`
	// 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:"-"`
	// 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:"-"`

	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:"-"`
}

// 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"`
}

// Section is one group of repositories on the index page. Name is what a
// repository's git config names it by (repo-render.section), so the
// title can change; Title is inline markdown and Description is markdown.
type Section struct {
	Name        string `yaml:"name"`
	Title       string `yaml:"title"`
	Description string `yaml:"description"`
}

// 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 {
	// 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"`
}

// 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",
		Log:               LogSyslog,
		Site:              Site{Title: "Code"},
		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.StateDir, &cfg.Site.Favicon} {
		if *p != "" && !filepath.IsAbs(*p) {
			*p = filepath.Join(base, *p)
		}
	}
	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))
	}
	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, "state_dir": c.StateDir,
	} {
		if v == "" {
			errs = append(errs, fmt.Errorf("%s is required", name))
		}
	}
	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))
		}
	}
	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))
			}
		}
	}
	names := map[string]bool{}
	for i, s := range c.Sections {
		if s.Title == "" {
			errs = append(errs, fmt.Errorf("sections[%d]: title is required", i))
		}
		switch {
		case s.Name == "":
			errs = append(errs, fmt.Errorf("sections[%d]: name is required: a short name, like tools, that repositories name it by", i))
		case !ValidSectionName(s.Name):
			errs = append(errs, fmt.Errorf("sections[%d]: name %q is not usable (letters, digits, - and _)", i, s.Name))
		case names[s.Name]:
			errs = append(errs, fmt.Errorf("sections[%d]: name %q is used twice", i, s.Name))
		}
		names[s.Name] = true
	}
	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))
		}
		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
		}
	}
	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 a repository in the section named section ("",
// or a name no section has, for none) is kept off the public site: it is
// in no section. It is on the private site, when there is one
// (PrivateHtdocs).
func (c *Config) Private(section string) bool {
	return c.SectionIndex(section) < 0
}

// SectionIndex returns the index of the section named name, or -1.
func (c *Config) SectionIndex(name string) int {
	if name == "" {
		return -1
	}
	return slices.IndexFunc(c.Sections, func(s Section) bool { return s.Name == name })
}

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

// ValidSectionName reports whether name can name a section.
func ValidSectionName(name string) bool { return sectionNameRE.MatchString(name) }

// ValidTag reports whether tag can be a repository's tag: not empty, with
// no control characters or surrounding spaces.
func ValidTag(tag string) bool {
	return tag != "" && strings.TrimSpace(tag) == tag && !strings.ContainsFunc(tag, unicode.IsControl)
}

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