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