Download
243 lines 10 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
package render

import (
	"net/url"
	"path/filepath"
	"strconv"
	"strings"

	"code.bruce-hill.com/repo-renderer/internal/config"
)

// Layout maps logical locations to paths. Each directory comes from its own
// config key (git_dir, htdocs, spool_dir, state_dir).
type Layout struct {
	GitBase  string // bare repositories, written by the git user
	Htdocs   string // the website: htdocs/<host>, served by httpd
	QueueDir string // the job queue: the only place the git user writes for us
	State    string // render state and the lock: the renderer's alone
}

// LayoutOf is the layout a config describes.
func LayoutOf(cfg *config.Config) Layout {
	return Layout{GitBase: cfg.GitDir, Htdocs: cfg.Htdocs, QueueDir: cfg.SpoolDir, State: cfg.StateDir}
}

// private is the layout of the private site whose root is htdocs: the
// same repositories and queue, and a site and state of its own, which
// keeps it apart from the public site's. httpd serves the root, and the
// site lives in its config.PrivateDir, the URL path it is served at.
func (l Layout) private(htdocs string) Layout {
	l.Htdocs = filepath.Join(htdocs, config.PrivateDir)
	l.State = filepath.Join(l.State, config.PrivateDir)
	return l
}

func (l Layout) GitDir(repo string) string    { return filepath.Join(l.GitBase, repo+".git") }
func (l Layout) QueuePath() string            { return filepath.Join(l.QueueDir, "queue") }
func (l Layout) StateDir() string             { return filepath.Join(l.State, "state") }
func (l Layout) StatePath(repo string) string { return filepath.Join(l.StateDir(), repo) }
func (l Layout) LockPath() string             { return filepath.Join(l.State, "render.lock") }

// PubBase is the public clone export, inside the site root so
// /git/<repo>.git is served by the same httpd server block as everything
// else. (The bare repositories themselves live outside it, under GitBase.)
func (l Layout) PubBase() string           { return filepath.Join(l.Htdocs, "git") }
func (l Layout) PubDir(repo string) string { return filepath.Join(l.PubBase(), repo+".git") }
func (l Layout) StaticDir() string         { return filepath.Join(l.Htdocs, "static") }
func (l Layout) IndexPage() string         { return filepath.Join(l.Htdocs, "index.html") }

// ErrorPage is the page httpd's errdocs shows for an HTTP status.
func (l Layout) ErrorPage(code int) string {
	return filepath.Join(l.ErrDir(), strconv.Itoa(code)+".html")
}

// ErrDir holds the error pages, which httpd's errdocs names by status.
func (l Layout) ErrDir() string              { return filepath.Join(l.Htdocs, "err") }
func (l Layout) NotFoundPage() string        { return l.ErrorPage(404) }
func (l Layout) RobotsTxt() string           { return filepath.Join(l.Htdocs, "robots.txt") }
func (l Layout) AdsTxt() string              { return filepath.Join(l.Htdocs, "ads.txt") }
func (l Layout) CodeBase() string            { return filepath.Join(l.Htdocs, "code") }
func (l Layout) RepoPage(repo string) string { return filepath.Join(l.CodeBase(), repo+".html") }
func (l Layout) RepoDir(repo string) string  { return filepath.Join(l.CodeBase(), repo) }
func (l Layout) RawDir(repo string) string   { return filepath.Join(l.Htdocs, "raw", repo) }
func (l Layout) ArchiveDir(repo string) string {
	return filepath.Join(l.Htdocs, "archive", repo)
}
func (l Layout) SnapshotDir(key string) string {
	return filepath.Join(l.Htdocs, "snapshot", key)
}

// SearchDir holds a view's search page, as its index.html, and the corpus
// it searches.
func (l Layout) SearchDir(key string) string  { return filepath.Join(l.Htdocs, "search", key) }
func (l Layout) SearchPage(key string) string { return filepath.Join(l.SearchDir(key), "index.html") }
func (l Layout) SearchData(key string) string { return filepath.Join(l.SearchDir(key), "corpus.json") }

// Page is the HTML file for a repo-relative path; "" is the repo root.
// Directories are files too: dir "src" → code/<repo>/src.html.
func (l Layout) Page(repo, p string) string {
	if p == "" {
		return l.RepoPage(repo)
	}
	return filepath.Join(l.RepoDir(repo), filepath.FromSlash(p)+".html")
}

// IframesDir holds the documents a view's pages show in iframes.
func (l Layout) IframesDir(key string) string { return filepath.Join(l.Htdocs, "iframes", key) }

// FileTree is a view's file tree document, the drawer on every page.
func (l Layout) FileTree(key string) string { return filepath.Join(l.IframesDir(key), "filetree.html") }

// Downloads is a view's download list document: its latest tarball, the
// commit it was made from, and the repo's tags.
func (l Layout) Downloads(key string) string {
	return filepath.Join(l.IframesDir(key), "downloads.html")
}

// SiteSitemap indexes the repositories' sitemaps.
func (l Layout) SiteSitemap() string { return filepath.Join(l.Htdocs, "sitemap.xml") }

// Sitemap lists the pages of a repository's default branch.
func (l Layout) Sitemap(repo string) string {
	return filepath.Join(l.Htdocs, "sitemap", repo+".xml")
}

// Raw is the on-disk path of a raw file given its served name.
func (l Layout) Raw(repo, name string) string {
	return filepath.Join(l.RawDir(repo), filepath.FromSlash(name))
}

// SVGDir holds derived copies of SVGs for <img> embedding: the raw copy
// stays byte-exact, the themed copy gets the theme colour injected so that
// currentColor resolves to the page foreground.
func (l Layout) SVGDir(repo string) string { return filepath.Join(l.Htdocs, "svg", repo) }

// SVG is the on-disk path of the themed copy of an SVG.
func (l Layout) SVG(repo, p string) string {
	return filepath.Join(l.SVGDir(repo), filepath.FromSlash(p))
}

// TypesDir holds the pages listing a view's files by type.
func (l Layout) TypesDir(repo string) string { return filepath.Join(l.Htdocs, "types", repo) }

// TypePage lists the files of one type in a size summary row.
func (l Layout) TypePage(repo, row, slug string) string {
	return filepath.Join(l.TypesDir(repo), row, slug+".html")
}

// Archive is the tarball path for a (sanitized) tag.
func (l Layout) Archive(repo, tag string) string {
	return filepath.Join(l.ArchiveDir(repo), repo+"-"+tag+".tar.gz")
}

// TipArchive is the path of a view's branch-tip tarball named by
// tipArchiveName. They have a directory of their own, apart from the tag
// tarballs, so httpd can cache them for good by their path alone.
func (l Layout) TipArchive(key, name string) string {
	return filepath.Join(l.SnapshotDir(key), name)
}

// tipArchiveName names the tarball of a branch tip after its commit, so
// its contents never change and it can be cached for good.
func tipArchiveName(repo, sha string) string {
	return repo + "@" + sha[:12] + ".tar.gz"
}

// ---- URLs ----

// CodeURL is the URL of a file or directory page.
func CodeURL(repo, p string) string {
	if p == "" {
		return "/code/" + repo
	}
	return "/code/" + repo + "/" + escapePath(p)
}

// FileTreeURL is the URL of a view's file tree document.
func FileTreeURL(key string) string { return "/iframes/" + key + "/filetree.html" }

// DownloadsURL is the URL of a view's download list document.
func DownloadsURL(key string) string { return "/iframes/" + key + "/downloads.html" }

// SitemapURL is the URL of a repository's sitemap.
func SitemapURL(repo string) string { return "/sitemap/" + repo + ".xml" }

// RawURL is the URL of a raw file given its served name.
func RawURL(repo, name string) string {
	return "/raw/" + repo + "/" + escapePath(name)
}

// SVGURL is the URL of the themed copy of an SVG.
func SVGURL(repo, p string) string {
	return "/svg/" + repo + "/" + escapePath(p)
}

// TypeURL is the URL of the page listing the files of one type.
func TypeURL(repo, row, slug string) string { return "/types/" + repo + "/" + row + "/" + slug }

// SearchURL is the URL of a view's search page: its directory, which httpd
// serves the index.html of. Without the slash, httpd redirects and drops
// the query, so the search form must submit here.
func SearchURL(key string) string { return "/search/" + key + "/" }

// SearchDataURL is the URL of a view's search corpus.
func SearchDataURL(key string) string { return "/search/" + key + "/corpus.json" }

// ArchiveURL is the URL of a tag tarball.
func ArchiveURL(repo, tag string) string {
	return "/archive/" + repo + "/" + escapePath(repo+"-"+tag+".tar.gz")
}

// TipArchiveURL is the URL of a view's branch-tip tarball.
func TipArchiveURL(key, name string) string {
	return "/snapshot/" + key + "/" + name
}

// escapePath percent-encodes each segment of a slash-separated path.
func escapePath(p string) string {
	segs := strings.Split(p, "/")
	for i, s := range segs {
		segs[i] = url.PathEscape(s)
	}
	return strings.Join(segs, "/")
}

// ValidPath rejects tree paths that could escape the output directory or
// break the filesystem: "..", empty components, leading "/", control chars.
func ValidPath(p string) bool {
	if p == "" || p[0] == '/' {
		return false
	}
	for _, c := range p {
		if c < 0x20 || c == 0x7f {
			return false
		}
	}
	for comp := range strings.SplitSeq(p, "/") {
		if comp == "" || comp == "." || comp == ".." {
			return false
		}
	}
	return true
}

// ---- URLs on a site ----

// A site's URLs are those above under its prefix: "" for the public site,
// config.PrivatePrefix for the private one.

func (s *Site) CodeURL(repo, p string) string      { return s.prefix + CodeURL(repo, p) }
func (s *Site) FileTreeURL(key string) string      { return s.prefix + FileTreeURL(key) }
func (s *Site) DownloadsURL(key string) string     { return s.prefix + DownloadsURL(key) }
func (s *Site) RawURL(repo, name string) string    { return s.prefix + RawURL(repo, name) }
func (s *Site) SVGURL(repo, p string) string       { return s.prefix + SVGURL(repo, p) }
func (s *Site) SearchURL(key string) string        { return s.prefix + SearchURL(key) }
func (s *Site) SearchDataURL(key string) string    { return s.prefix + SearchDataURL(key) }
func (s *Site) ArchiveURL(repo, tag string) string { return s.prefix + ArchiveURL(repo, tag) }
func (s *Site) TipArchiveURL(key, name string) string {
	return s.prefix + TipArchiveURL(key, name)
}
func (s *Site) TypeURL(repo, row, slug string) string {
	return s.prefix + TypeURL(repo, row, slug)
}