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