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