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