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