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/formatters/html"
12 "github.com/alecthomas/chroma/v2/lexers"
13 "github.com/alecthomas/chroma/v2/styles"
14 "github.com/charmbracelet/log/v2"
15 "github.com/charmbracelet/soft-serve/pkg/config"
16 "github.com/charmbracelet/soft-serve/pkg/proto"
17 "github.com/gorilla/mux"
18)
19
20// BlobData contains data for rendering file content view.
21type BlobData struct {
22 Repo proto.Repository
23 DefaultBranch string
24 Ref string
25 Path string
26 Content string
27 RenderedHTML template.HTML
28 IsBinary bool
29 IsMarkdown bool
30 ShowSource bool
31 ActiveTab string
32 ServerName string
33 IsCommitHash bool
34}
35
36// repoBlob handles file content view.
37func repoBlob(w http.ResponseWriter, r *http.Request) {
38 ctx := r.Context()
39 logger := log.FromContext(ctx)
40 cfg := config.FromContext(ctx)
41 repo := proto.RepositoryFromContext(ctx)
42
43 if r.URL.Query().Get("raw") == "1" {
44 repoBlobRaw(w, r)
45 return
46 }
47
48 gr, err := openRepository(repo)
49 if err != nil {
50 logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
51 renderInternalServerError(w, r)
52 return
53 }
54
55 vars := mux.Vars(r)
56 refAndPath := vars["refAndPath"]
57 ref, path := parseRefAndPath(gr, refAndPath)
58
59 refObj, isCommitHash, err := resolveAndBuildRef(gr, ref)
60 if err != nil {
61 logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
62 renderNotFound(w, r)
63 return
64 }
65
66 tree, err := gr.Tree(refObj)
67 if err != nil {
68 logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err)
69 renderNotFound(w, r)
70 return
71 }
72
73 entry, err := tree.TreeEntry(path)
74 if err != nil {
75 logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
76 renderNotFound(w, r)
77 return
78 }
79
80 if entry.IsTree() {
81 renderNotFound(w, r)
82 return
83 }
84
85 content, err := entry.Contents()
86 if err != nil {
87 logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
88 renderInternalServerError(w, r)
89 return
90 }
91
92 defaultBranch := getDefaultBranch(gr)
93
94 isMarkdown := isMarkdownFile(path)
95 showSource := r.URL.Query().Get("source") == "1"
96 var renderedHTML template.HTML
97
98 if isMarkdown && !isBinaryContent(content) && !showSource {
99 renderedHTML, _ = renderMarkdown(content)
100 } else if !isBinaryContent(content) {
101 renderedHTML = highlightCode(path, content)
102 }
103
104 data := BlobData{
105 Repo: repo,
106 DefaultBranch: defaultBranch,
107 Ref: ref,
108 Path: path,
109 Content: string(content),
110 RenderedHTML: renderedHTML,
111 IsBinary: isBinaryContent(content),
112 IsMarkdown: isMarkdown,
113 ShowSource: showSource,
114 ActiveTab: "tree",
115 ServerName: cfg.Name,
116 IsCommitHash: isCommitHash,
117 }
118
119 renderHTML(w, "blob.html", data)
120}
121
122// repoBlobRaw handles raw file download.
123func repoBlobRaw(w http.ResponseWriter, r *http.Request) {
124 ctx := r.Context()
125 logger := log.FromContext(ctx)
126 repo := proto.RepositoryFromContext(ctx)
127
128 gr, err := openRepository(repo)
129 if err != nil {
130 logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
131 renderInternalServerError(w, r)
132 return
133 }
134
135 vars := mux.Vars(r)
136 refAndPath := vars["refAndPath"]
137 ref, path := parseRefAndPath(gr, refAndPath)
138
139 refObj, _, err := resolveAndBuildRef(gr, ref)
140 if err != nil {
141 logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
142 renderNotFound(w, r)
143 return
144 }
145
146 tree, err := gr.Tree(refObj)
147 if err != nil {
148 logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err)
149 renderNotFound(w, r)
150 return
151 }
152
153 entry, err := tree.TreeEntry(path)
154 if err != nil {
155 logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
156 renderNotFound(w, r)
157 return
158 }
159
160 if entry.IsTree() {
161 renderNotFound(w, r)
162 return
163 }
164
165 content, err := entry.Contents()
166 if err != nil {
167 logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
168 renderInternalServerError(w, r)
169 return
170 }
171
172 contentType := mime.TypeByExtension(filepath.Ext(path))
173 if contentType == "" {
174 contentType = http.DetectContentType(content)
175 }
176 if strings.HasPrefix(contentType, "text/") && !strings.Contains(contentType, "charset") {
177 contentType += "; charset=utf-8"
178 }
179
180 w.Header().Set("Content-Type", contentType)
181 _, _ = w.Write(content)
182}
183
184// isBinaryContent detects if file content is binary using a simple heuristic.
185// Returns true if content contains null bytes in the first 8KB.
186func isBinaryContent(content []byte) bool {
187 // Check first 8KB for null bytes (Git's detection method)
188 size := len(content)
189 if size > 8000 {
190 size = 8000
191 }
192 for i := 0; i < size; i++ {
193 if content[i] == 0 {
194 return true
195 }
196 }
197 return false
198}
199
200// isMarkdownFile checks if a file has a markdown extension.
201func isMarkdownFile(path string) bool {
202 ext := strings.ToLower(filepath.Ext(path))
203 return ext == ".md" || ext == ".markdown"
204}
205
206// highlightCode applies syntax highlighting to code and returns HTML.
207func highlightCode(path string, content []byte) template.HTML {
208 lexer := lexers.Match(path)
209 if lexer == nil {
210 lexer = lexers.Analyse(string(content))
211 }
212 if lexer == nil {
213 lexer = lexers.Fallback
214 }
215
216 style := styles.Get("github")
217 if style == nil {
218 style = styles.Fallback
219 }
220
221 formatter := html.New(
222 html.WithClasses(true),
223 html.WithLineNumbers(true),
224 html.WithLinkableLineNumbers(true, ""),
225 )
226
227 iterator, err := lexer.Tokenise(nil, string(content))
228 if err != nil {
229 return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
230 }
231
232 var buf bytes.Buffer
233 err = formatter.Format(&buf, style, iterator)
234 if err != nil {
235 return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
236 }
237
238 return template.HTML(buf.String())
239}