ความคืบหน้างาน ไฟล์ต้นฉบับ

tracker/files.go

package main

import (
	"html/template"
	"io/fs"
	"net/http"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"sort"
	"strings"
)

// File viewer: /files/<path> 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), "<table>")
			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)
}