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