webui_blob.go

  1package web
  2
  3import (
  4	"bytes"
  5	"html/template"
  6	"mime"
  7	"net/http"
  8	"path/filepath"
  9	"strings"
 10
 11	"github.com/alecthomas/chroma/v2/formatters/html"
 12	"github.com/alecthomas/chroma/v2/lexers"
 13	"github.com/alecthomas/chroma/v2/styles"
 14	"github.com/charmbracelet/log/v2"
 15	"github.com/charmbracelet/soft-serve/pkg/config"
 16	"github.com/charmbracelet/soft-serve/pkg/proto"
 17	"github.com/gorilla/mux"
 18)
 19
 20// BlobData contains data for rendering file content view.
 21type BlobData struct {
 22	Repo          proto.Repository
 23	DefaultBranch string
 24	Ref           string
 25	Path          string
 26	Content       string
 27	RenderedHTML  template.HTML
 28	IsBinary      bool
 29	IsMarkdown    bool
 30	ShowSource    bool
 31	ActiveTab     string
 32	ServerName    string
 33	IsCommitHash  bool
 34}
 35
 36// repoBlob handles file content view.
 37func repoBlob(w http.ResponseWriter, r *http.Request) {
 38	ctx := r.Context()
 39	logger := log.FromContext(ctx)
 40	cfg := config.FromContext(ctx)
 41	repo := proto.RepositoryFromContext(ctx)
 42
 43	if r.URL.Query().Get("raw") == "1" {
 44		repoBlobRaw(w, r)
 45		return
 46	}
 47
 48	gr, err := openRepository(repo)
 49	if err != nil {
 50		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
 51		renderInternalServerError(w, r)
 52		return
 53	}
 54
 55	vars := mux.Vars(r)
 56	refAndPath := vars["refAndPath"]
 57	ref, path := parseRefAndPath(gr, refAndPath)
 58
 59	refObj, isCommitHash, err := resolveAndBuildRef(gr, ref)
 60	if err != nil {
 61		logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
 62		renderNotFound(w, r)
 63		return
 64	}
 65
 66	tree, err := gr.Tree(refObj)
 67	if err != nil {
 68		logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err)
 69		renderNotFound(w, r)
 70		return
 71	}
 72
 73	entry, err := tree.TreeEntry(path)
 74	if err != nil {
 75		logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
 76		renderNotFound(w, r)
 77		return
 78	}
 79
 80	if entry.IsTree() {
 81		renderNotFound(w, r)
 82		return
 83	}
 84
 85	content, err := entry.Contents()
 86	if err != nil {
 87		logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
 88		renderInternalServerError(w, r)
 89		return
 90	}
 91
 92	defaultBranch := getDefaultBranch(gr)
 93
 94	isMarkdown := isMarkdownFile(path)
 95	showSource := r.URL.Query().Get("source") == "1"
 96	var renderedHTML template.HTML
 97
 98	if isMarkdown && !isBinaryContent(content) && !showSource {
 99		renderedHTML, _ = renderMarkdown(content)
100	} else if !isBinaryContent(content) {
101		renderedHTML = highlightCode(path, content)
102	}
103
104	data := BlobData{
105		Repo:          repo,
106		DefaultBranch: defaultBranch,
107		Ref:           ref,
108		Path:          path,
109		Content:       string(content),
110		RenderedHTML:  renderedHTML,
111		IsBinary:      isBinaryContent(content),
112		IsMarkdown:    isMarkdown,
113		ShowSource:    showSource,
114		ActiveTab:     "tree",
115		ServerName:    cfg.Name,
116		IsCommitHash:  isCommitHash,
117	}
118
119	renderHTML(w, "blob.html", data)
120}
121
122// repoBlobRaw handles raw file download.
123func repoBlobRaw(w http.ResponseWriter, r *http.Request) {
124	ctx := r.Context()
125	logger := log.FromContext(ctx)
126	repo := proto.RepositoryFromContext(ctx)
127
128	gr, err := openRepository(repo)
129	if err != nil {
130		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
131		renderInternalServerError(w, r)
132		return
133	}
134
135	vars := mux.Vars(r)
136	refAndPath := vars["refAndPath"]
137	ref, path := parseRefAndPath(gr, refAndPath)
138
139	refObj, _, err := resolveAndBuildRef(gr, ref)
140	if err != nil {
141		logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
142		renderNotFound(w, r)
143		return
144	}
145
146	tree, err := gr.Tree(refObj)
147	if err != nil {
148		logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err)
149		renderNotFound(w, r)
150		return
151	}
152
153	entry, err := tree.TreeEntry(path)
154	if err != nil {
155		logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
156		renderNotFound(w, r)
157		return
158	}
159
160	if entry.IsTree() {
161		renderNotFound(w, r)
162		return
163	}
164
165	content, err := entry.Contents()
166	if err != nil {
167		logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
168		renderInternalServerError(w, r)
169		return
170	}
171
172	contentType := mime.TypeByExtension(filepath.Ext(path))
173	if contentType == "" {
174		contentType = http.DetectContentType(content)
175	}
176	if strings.HasPrefix(contentType, "text/") && !strings.Contains(contentType, "charset") {
177		contentType += "; charset=utf-8"
178	}
179
180	w.Header().Set("Content-Type", contentType)
181	_, _ = w.Write(content)
182}
183
184// isBinaryContent detects if file content is binary using a simple heuristic.
185// Returns true if content contains null bytes in the first 8KB.
186func isBinaryContent(content []byte) bool {
187	// Check first 8KB for null bytes (Git's detection method)
188	size := len(content)
189	if size > 8000 {
190		size = 8000
191	}
192	for i := 0; i < size; i++ {
193		if content[i] == 0 {
194			return true
195		}
196	}
197	return false
198}
199
200// isMarkdownFile checks if a file has a markdown extension.
201func isMarkdownFile(path string) bool {
202	ext := strings.ToLower(filepath.Ext(path))
203	return ext == ".md" || ext == ".markdown"
204}
205
206// highlightCode applies syntax highlighting to code and returns HTML.
207func highlightCode(path string, content []byte) template.HTML {
208	lexer := lexers.Match(path)
209	if lexer == nil {
210		lexer = lexers.Analyse(string(content))
211	}
212	if lexer == nil {
213		lexer = lexers.Fallback
214	}
215
216	style := styles.Get("github")
217	if style == nil {
218		style = styles.Fallback
219	}
220
221	formatter := html.New(
222		html.WithClasses(true),
223		html.WithLineNumbers(true),
224		html.WithLinkableLineNumbers(true, ""),
225	)
226
227	iterator, err := lexer.Tokenise(nil, string(content))
228	if err != nil {
229		return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
230	}
231
232	var buf bytes.Buffer
233	err = formatter.Format(&buf, style, iterator)
234	if err != nil {
235		return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
236	}
237
238	return template.HTML(buf.String())
239}