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"
 12	"github.com/alecthomas/chroma/v2/formatters/html"
 13	"github.com/alecthomas/chroma/v2/lexers"
 14	"github.com/alecthomas/chroma/v2/styles"
 15	"github.com/charmbracelet/log/v2"
 16	"github.com/charmbracelet/soft-serve/pkg/config"
 17	"github.com/charmbracelet/soft-serve/pkg/proto"
 18	"github.com/gorilla/mux"
 19)
 20
 21// BlobData contains data for rendering file content view.
 22type BlobData struct {
 23	RepoBaseData
 24	Ref          string
 25	Path         string
 26	Content      string
 27	RenderedHTML template.HTML
 28	IsBinary     bool
 29	IsMarkdown   bool
 30	ShowSource   bool
 31	IsCommitHash bool
 32}
 33
 34// repoBlob handles file content view.
 35func repoBlob(w http.ResponseWriter, r *http.Request) {
 36	ctx := r.Context()
 37	logger := log.FromContext(ctx)
 38	cfg := config.FromContext(ctx)
 39	repo := proto.RepositoryFromContext(ctx)
 40
 41	if r.URL.Query().Get("raw") == "1" {
 42		repoBlobRaw(w, r)
 43		return
 44	}
 45
 46	gr, err := openRepository(repo)
 47	if err != nil {
 48		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
 49		renderInternalServerError(w, r)
 50		return
 51	}
 52
 53	vars := mux.Vars(r)
 54	refAndPath := vars["refAndPath"]
 55	ref, path := parseRefAndPath(gr, refAndPath)
 56
 57	refObj, isCommitHash, err := resolveAndBuildRef(gr, ref)
 58	if err != nil {
 59		logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
 60		renderNotFound(w, r)
 61		return
 62	}
 63
 64	tree, err := gr.Tree(refObj)
 65	if err != nil {
 66		logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err)
 67		renderNotFound(w, r)
 68		return
 69	}
 70
 71	entry, err := tree.TreeEntry(path)
 72	if err != nil {
 73		logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
 74		renderNotFound(w, r)
 75		return
 76	}
 77
 78	if entry.IsTree() {
 79		renderNotFound(w, r)
 80		return
 81	}
 82
 83	content, err := entry.Contents()
 84	if err != nil {
 85		logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
 86		renderInternalServerError(w, r)
 87		return
 88	}
 89
 90	defaultBranch := getDefaultBranch(gr)
 91
 92	isMarkdown := isMarkdownFile(path, content)
 93	showSource := r.URL.Query().Get("source") == "1"
 94	var renderedHTML template.HTML
 95
 96	if isMarkdown && !isBinaryContent(content) && !showSource {
 97		ctx := &ReadmeContext{
 98			RepoName:   repo.Name(),
 99			CommitHash: refObj.Reference.ID,
100			ReadmePath: path,
101		}
102		var err error
103		renderedHTML, err = renderMarkdown(content, ctx)
104		if err != nil {
105			logger.Debug("failed to render markdown", "repo", repo.Name(), "path", path, "err", err)
106		}
107	} else if !isBinaryContent(content) {
108		renderedHTML = highlightCode(path, content)
109	}
110
111	repoDisplayName := repo.ProjectName()
112	if repoDisplayName == "" {
113		repoDisplayName = repo.Name()
114	}
115	fileName := filepath.Base(path)
116
117	description := getRepoDescriptionOrFallback(repo, "View "+fileName+" in "+repoDisplayName)
118
119	data := BlobData{
120		RepoBaseData: RepoBaseData{
121			BaseData: BaseData{
122				ServerName:  cfg.Name,
123				ActiveTab:   "tree",
124				Title:       fileName + " | " + repoDisplayName,
125				Description: description,
126			},
127			Repo:          repo,
128			DefaultBranch: defaultBranch,
129		},
130		Ref:          ref,
131		Path:         path,
132		Content:      string(content),
133		RenderedHTML: renderedHTML,
134		IsBinary:     isBinaryContent(content),
135		IsMarkdown:   isMarkdown,
136		ShowSource:   showSource,
137		IsCommitHash: isCommitHash,
138	}
139
140	renderHTML(w, "blob.html", data)
141}
142
143// repoBlobRaw handles raw file download.
144func repoBlobRaw(w http.ResponseWriter, r *http.Request) {
145	ctx := r.Context()
146	logger := log.FromContext(ctx)
147	repo := proto.RepositoryFromContext(ctx)
148
149	gr, err := openRepository(repo)
150	if err != nil {
151		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
152		renderInternalServerError(w, r)
153		return
154	}
155
156	vars := mux.Vars(r)
157	refAndPath := vars["refAndPath"]
158	ref, path := parseRefAndPath(gr, refAndPath)
159
160	refObj, _, err := resolveAndBuildRef(gr, ref)
161	if err != nil {
162		logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
163		renderNotFound(w, r)
164		return
165	}
166
167	tree, err := gr.Tree(refObj)
168	if err != nil {
169		logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err)
170		renderNotFound(w, r)
171		return
172	}
173
174	entry, err := tree.TreeEntry(path)
175	if err != nil {
176		logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
177		renderNotFound(w, r)
178		return
179	}
180
181	if entry.IsTree() {
182		renderNotFound(w, r)
183		return
184	}
185
186	content, err := entry.Contents()
187	if err != nil {
188		logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
189		renderInternalServerError(w, r)
190		return
191	}
192
193	ext := filepath.Ext(path)
194	var contentType string
195
196	// For files without extensions, prioritize content detection
197	if ext == "" {
198		contentType = http.DetectContentType(content)
199	} else {
200		// For files with extensions, try MIME type lookup first
201		contentType = mime.TypeByExtension(ext)
202		if contentType == "" {
203			contentType = http.DetectContentType(content)
204		}
205	}
206
207	if strings.HasPrefix(contentType, "text/") && !strings.Contains(contentType, "charset") {
208		contentType += "; charset=utf-8"
209	}
210
211	w.Header().Set("Content-Type", contentType)
212	_, _ = w.Write(content)
213}
214
215// isBinaryContent detects if file content is binary using a simple heuristic.
216// Returns true if content contains null bytes in the first 8KB.
217func isBinaryContent(content []byte) bool {
218	// Check first 8KB for null bytes (Git's detection method)
219	size := len(content)
220	if size > 8000 {
221		size = 8000
222	}
223	for i := 0; i < size; i++ {
224		if content[i] == 0 {
225			return true
226		}
227	}
228	return false
229}
230
231// isMarkdownFile checks if a file has a markdown extension or contains markdown content.
232func isMarkdownFile(path string, content []byte) bool {
233	ext := strings.ToLower(filepath.Ext(path))
234	if ext == ".md" || ext == ".markdown" {
235		return true
236	}
237
238	// For files without extensions, use Chroma's lexer detection
239	if ext == "" && len(content) > 0 {
240		lexer := lexers.Analyse(string(content))
241		if lexer != nil {
242			config := lexer.Config()
243			name := strings.ToLower(config.Name)
244			return name == "markdown" || name == "md"
245		}
246	}
247
248	return false
249}
250
251// highlightCode applies syntax highlighting to code and returns HTML.
252func highlightCode(path string, content []byte) template.HTML {
253	var lexer chroma.Lexer
254	ext := filepath.Ext(path)
255
256	// For files without extensions, prioritize content analysis to detect shebangs
257	if ext == "" {
258		lexer = lexers.Analyse(string(content))
259		if lexer == nil {
260			lexer = lexers.Match(path)
261		}
262	} else {
263		// For files with extensions, try filename matching first
264		lexer = lexers.Match(path)
265		if lexer == nil {
266			lexer = lexers.Analyse(string(content))
267		}
268	}
269
270	if lexer == nil {
271		lexer = lexers.Fallback
272	}
273
274	style := styles.Get("github")
275	if style == nil {
276		style = styles.Fallback
277	}
278
279	formatter := html.New(
280		html.WithClasses(true),
281		html.WithLineNumbers(true),
282		html.WithLinkableLineNumbers(true, ""),
283	)
284
285	iterator, err := lexer.Tokenise(nil, string(content))
286	if err != nil {
287		return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
288	}
289
290	var buf bytes.Buffer
291	err = formatter.Format(&buf, style, iterator)
292	if err != nil {
293		return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
294	}
295
296	return template.HTML(buf.String())
297}