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