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