package main
import (
"html/template"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
// File viewer: /files/ shows a file from the repository so refs in the
// tracker can be opened in a new tab. Markdown is rendered as a readable page
// (contents, tables, row filter); text and code are shown as is; images are
// served directly; folders list their files.
//
// Only files inside repoRoot are served. Hidden paths (.git, .env, …) and
// key files are refused, and symlinks may not lead outside the repository.
var repoRoot = ".." // set from the -root flag
var textExts = map[string]bool{
".go": true, ".js": true, ".mjs": true, ".css": true, ".html": true, ".json": true, ".txt": true,
".py": true, ".sh": true, ".yml": true, ".yaml": true, ".toml": true, ".mod": true, ".sum": true,
".drawio": true, ".csv": true, ".xml": true, ".sql": true,
}
var imageTypes = map[string]string{
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
".svg": "image/svg+xml", ".webp": "image/webp",
}
var blockedExts = map[string]bool{".pem": true, ".key": true, ".p12": true, ".pfx": true}
const maxViewBytes = 2 << 20
// resolveRepoPath maps a slash path to a file inside the repository, or
// reports false if it is outside, hidden, blocked or missing.
func resolveRepoPath(rel string) (string, fs.FileInfo, bool) {
rel = strings.TrimSpace(rel)
if rel == "" || strings.ContainsRune(rel, 0) {
return "", nil, false
}
clean := strings.TrimPrefix(path.Clean("/"+rel), "/")
for _, seg := range strings.Split(clean, "/") {
if strings.HasPrefix(seg, ".") {
return "", nil, false
}
}
if blockedExts[strings.ToLower(path.Ext(clean))] {
return "", nil, false
}
root, err := filepath.EvalSymlinks(repoRoot)
if err != nil {
return "", nil, false
}
real, err := filepath.EvalSymlinks(filepath.Join(root, filepath.FromSlash(clean)))
if err != nil {
return "", nil, false
}
if real != root && !strings.HasPrefix(real, root+string(filepath.Separator)) {
return "", nil, false
}
info, err := os.Stat(real)
if err != nil {
return "", nil, false
}
return real, info, true
}
// refHref returns the viewer link for a repo path, or "" when the text is not
// a viewable file or folder in the repository.
func refHref(ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" || strings.Contains(ref, "://") || len(ref) > 300 {
return ""
}
_, info, ok := resolveRepoPath(ref)
if !ok {
return ""
}
clean := strings.TrimPrefix(path.Clean("/"+ref), "/")
parts := strings.Split(clean, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
href := "/files/" + strings.Join(parts, "/")
if info.IsDir() {
href += "/"
}
return href
}
// FilePage is the data for templates/file.html.
type FilePage struct {
Title string
Path string // repo-relative path
Crumbs []Crumb // folder trail
Kind string // markdown | text | dir | other
HTML template.HTML
Text string
Entries []DirEntry
TOC []TOCItem
RawHref string
Missing bool
Filtered bool // markdown with tables: show the row filter
}
type DirEntry struct {
Name, Href string
IsDir bool
}
type TOCItem struct {
Level int
Text, ID string
}
// fileCrumbs links every folder above the current file or folder.
func fileCrumbs(rel string) []Crumb {
crumbs := []Crumb{{"repo", "/files/"}}
if rel == "" {
crumbs[0].Href = ""
return crumbs
}
parts := strings.Split(rel, "/")
for i, p := range parts {
href := ""
if i < len(parts)-1 {
href = "/files/" + strings.Join(escapeAll(parts[:i+1]), "/") + "/"
}
crumbs = append(crumbs, Crumb{p, href})
}
return crumbs
}
func escapeAll(parts []string) []string {
out := make([]string, len(parts))
for i, p := range parts {
out[i] = url.PathEscape(p)
}
return out
}
func filesHandler(tpl *template.Template, render404 func(http.ResponseWriter)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rel := strings.Trim(r.PathValue("path"), "/")
var abs string
var info fs.FileInfo
if rel == "" {
root, err := filepath.EvalSymlinks(repoRoot)
if err != nil {
render404(w)
return
}
fi, err := os.Stat(root)
if err != nil {
render404(w)
return
}
abs, info = root, fi
} else {
var ok bool
abs, info, ok = resolveRepoPath(rel)
if !ok {
render404(w)
return
}
}
ext := strings.ToLower(filepath.Ext(abs))
// Raw download (and images) go straight to the browser.
if !info.IsDir() && (r.URL.Query().Get("raw") == "1" || imageTypes[ext] != "") {
if ct := imageTypes[ext]; ct != "" {
w.Header().Set("Content-Type", ct)
if ext == ".svg" {
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
}
} else if !textExts[ext] && ext != ".md" {
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(info.Name(), `"`, "")+`"`)
} else {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(w, r, abs)
return
}
fp := FilePage{Title: rel, Path: rel, Crumbs: fileCrumbs(rel)}
if rel == "" {
fp.Title = "repo"
}
switch {
case info.IsDir():
if !strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
return
}
fp.Kind = "dir"
entries, _ := os.ReadDir(abs)
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") || blockedExts[strings.ToLower(filepath.Ext(e.Name()))] {
continue
}
href := url.PathEscape(e.Name())
if e.IsDir() {
href += "/"
}
fp.Entries = append(fp.Entries, DirEntry{Name: e.Name(), Href: href, IsDir: e.IsDir()})
}
sort.SliceStable(fp.Entries, func(i, j int) bool { return fp.Entries[i].IsDir && !fp.Entries[j].IsDir })
case info.Size() > maxViewBytes:
fp.Kind = "other"
case ext == ".md":
b, err := os.ReadFile(abs)
if err != nil {
render404(w)
return
}
src := string(b)
fp.Kind = "markdown"
fp.HTML = renderMarkdown(src, mdOptions{headingIDs: true})
fp.TOC = tocOf(src)
fp.Filtered = strings.Contains(string(fp.HTML), "")
if len(fp.TOC) > 0 && fp.TOC[0].Level == 1 {
fp.Title = fp.TOC[0].Text
}
case textExts[ext] || ext == "":
b, err := os.ReadFile(abs)
if err != nil {
render404(w)
return
}
fp.Kind = "text"
fp.Text = string(b)
default:
fp.Kind = "other"
}
if !info.IsDir() {
fp.RawHref = "?raw=1"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := tpl.ExecuteTemplate(w, "file", fp); err != nil {
http.Error(w, "render error", http.StatusInternalServerError)
}
}
}
// tocOf lists the ## and ### headings with the same ids the renderer gives them.
func tocOf(src string) []TOCItem {
r := mdRenderer{ids: map[string]int{}}
var toc []TOCItem
inFence := false
for _, line := range strings.Split(src, "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "```") {
inFence = !inFence
continue
}
if inFence {
continue
}
m := reHeading.FindStringSubmatch(t)
if m == nil {
continue
}
id := r.slug(m[2]) // same counter order as the renderer
level := len(m[1])
if level == 1 || level > 3 {
if level == 1 {
toc = append(toc, TOCItem{Level: 1, Text: plainText(m[2]), ID: id})
}
continue
}
toc = append(toc, TOCItem{Level: level, Text: plainText(m[2]), ID: id})
}
return toc
}
func plainText(s string) string {
s = reLink.ReplaceAllString(s, "$1")
return strings.NewReplacer("`", "", "**", "", "*", "").Replace(s)
}