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))
}
|