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 renderedHTML, _ = renderMarkdown(content)
98 } else if !isBinaryContent(content) {
99 renderedHTML = highlightCode(path, content)
100 }
101
102 repoDisplayName := repo.ProjectName()
103 if repoDisplayName == "" {
104 repoDisplayName = repo.Name()
105 }
106 fileName := filepath.Base(path)
107
108 data := BlobData{
109 RepoBaseData: RepoBaseData{
110 BaseData: BaseData{
111 ServerName: cfg.Name,
112 ActiveTab: "tree",
113 Title: fileName + " | " + repoDisplayName,
114 },
115 Repo: repo,
116 DefaultBranch: defaultBranch,
117 },
118 Ref: ref,
119 Path: path,
120 Content: string(content),
121 RenderedHTML: renderedHTML,
122 IsBinary: isBinaryContent(content),
123 IsMarkdown: isMarkdown,
124 ShowSource: showSource,
125 IsCommitHash: isCommitHash,
126 }
127
128 renderHTML(w, "blob.html", data)
129}
130
131// repoBlobRaw handles raw file download.
132func repoBlobRaw(w http.ResponseWriter, r *http.Request) {
133 ctx := r.Context()
134 logger := log.FromContext(ctx)
135 repo := proto.RepositoryFromContext(ctx)
136
137 gr, err := openRepository(repo)
138 if err != nil {
139 logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
140 renderInternalServerError(w, r)
141 return
142 }
143
144 vars := mux.Vars(r)
145 refAndPath := vars["refAndPath"]
146 ref, path := parseRefAndPath(gr, refAndPath)
147
148 refObj, _, err := resolveAndBuildRef(gr, ref)
149 if err != nil {
150 logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
151 renderNotFound(w, r)
152 return
153 }
154
155 tree, err := gr.Tree(refObj)
156 if err != nil {
157 logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err)
158 renderNotFound(w, r)
159 return
160 }
161
162 entry, err := tree.TreeEntry(path)
163 if err != nil {
164 logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
165 renderNotFound(w, r)
166 return
167 }
168
169 if entry.IsTree() {
170 renderNotFound(w, r)
171 return
172 }
173
174 content, err := entry.Contents()
175 if err != nil {
176 logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
177 renderInternalServerError(w, r)
178 return
179 }
180
181 ext := filepath.Ext(path)
182 var contentType string
183
184 // For files without extensions, prioritize content detection
185 if ext == "" {
186 contentType = http.DetectContentType(content)
187 } else {
188 // For files with extensions, try MIME type lookup first
189 contentType = mime.TypeByExtension(ext)
190 if contentType == "" {
191 contentType = http.DetectContentType(content)
192 }
193 }
194
195 if strings.HasPrefix(contentType, "text/") && !strings.Contains(contentType, "charset") {
196 contentType += "; charset=utf-8"
197 }
198
199 w.Header().Set("Content-Type", contentType)
200 _, _ = w.Write(content)
201}
202
203// isBinaryContent detects if file content is binary using a simple heuristic.
204// Returns true if content contains null bytes in the first 8KB.
205func isBinaryContent(content []byte) bool {
206 // Check first 8KB for null bytes (Git's detection method)
207 size := len(content)
208 if size > 8000 {
209 size = 8000
210 }
211 for i := 0; i < size; i++ {
212 if content[i] == 0 {
213 return true
214 }
215 }
216 return false
217}
218
219// isMarkdownFile checks if a file has a markdown extension or contains markdown content.
220func isMarkdownFile(path string, content []byte) bool {
221 ext := strings.ToLower(filepath.Ext(path))
222 if ext == ".md" || ext == ".markdown" {
223 return true
224 }
225
226 // For files without extensions, use Chroma's lexer detection
227 if ext == "" && len(content) > 0 {
228 lexer := lexers.Analyse(string(content))
229 if lexer != nil {
230 config := lexer.Config()
231 name := strings.ToLower(config.Name)
232 return name == "markdown" || name == "md"
233 }
234 }
235
236 return false
237}
238
239// highlightCode applies syntax highlighting to code and returns HTML.
240func highlightCode(path string, content []byte) template.HTML {
241 var lexer chroma.Lexer
242 ext := filepath.Ext(path)
243
244 // For files without extensions, prioritize content analysis to detect shebangs
245 if ext == "" {
246 lexer = lexers.Analyse(string(content))
247 if lexer == nil {
248 lexer = lexers.Match(path)
249 }
250 } else {
251 // For files with extensions, try filename matching first
252 lexer = lexers.Match(path)
253 if lexer == nil {
254 lexer = lexers.Analyse(string(content))
255 }
256 }
257
258 if lexer == nil {
259 lexer = lexers.Fallback
260 }
261
262 style := styles.Get("github")
263 if style == nil {
264 style = styles.Fallback
265 }
266
267 formatter := html.New(
268 html.WithClasses(true),
269 html.WithLineNumbers(true),
270 html.WithLinkableLineNumbers(true, ""),
271 )
272
273 iterator, err := lexer.Tokenise(nil, string(content))
274 if err != nil {
275 return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
276 }
277
278 var buf bytes.Buffer
279 err = formatter.Format(&buf, style, iterator)
280 if err != nil {
281 return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
282 }
283
284 return template.HTML(buf.String())
285}