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