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