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