1package web
2
3import (
4 "bytes"
5 "html/template"
6 "net/http"
7 "path/filepath"
8 "strings"
9
10 "github.com/alecthomas/chroma/v2/formatters/html"
11 "github.com/alecthomas/chroma/v2/lexers"
12 "github.com/alecthomas/chroma/v2/styles"
13 "github.com/charmbracelet/log/v2"
14 "github.com/charmbracelet/soft-serve/pkg/config"
15 "github.com/charmbracelet/soft-serve/pkg/proto"
16 "github.com/gorilla/mux"
17)
18
19// BlobData contains data for rendering file content view.
20type BlobData struct {
21 Repo proto.Repository
22 DefaultBranch string
23 Ref string
24 Path string
25 Content string
26 RenderedHTML template.HTML
27 IsBinary bool
28 IsMarkdown bool
29 ShowSource bool
30 ActiveTab string
31 ServerName string
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, 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)
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 Repo: repo,
104 DefaultBranch: defaultBranch,
105 Ref: ref,
106 Path: path,
107 Content: string(content),
108 RenderedHTML: renderedHTML,
109 IsBinary: isBinaryContent(content),
110 IsMarkdown: isMarkdown,
111 ShowSource: showSource,
112 ActiveTab: "tree",
113 ServerName: cfg.Name,
114 }
115
116 renderHTML(w, "blob.html", data)
117}
118
119// repoBlobRaw handles raw file download.
120func repoBlobRaw(w http.ResponseWriter, r *http.Request) {
121 ctx := r.Context()
122 logger := log.FromContext(ctx)
123 repo := proto.RepositoryFromContext(ctx)
124
125 gr, err := openRepository(repo)
126 if err != nil {
127 logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
128 renderInternalServerError(w, r)
129 return
130 }
131
132 vars := mux.Vars(r)
133 refAndPath := vars["refAndPath"]
134 ref, path := parseRefAndPath(gr, refAndPath)
135
136 refObj, err := resolveAndBuildRef(gr, ref)
137 if err != nil {
138 logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
139 renderNotFound(w, r)
140 return
141 }
142
143 tree, err := gr.Tree(refObj)
144 if err != nil {
145 logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err)
146 renderNotFound(w, r)
147 return
148 }
149
150 entry, err := tree.TreeEntry(path)
151 if err != nil {
152 logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
153 renderNotFound(w, r)
154 return
155 }
156
157 if entry.IsTree() {
158 renderNotFound(w, r)
159 return
160 }
161
162 content, err := entry.Contents()
163 if err != nil {
164 logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
165 renderInternalServerError(w, r)
166 return
167 }
168
169 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
170 w.Write(content)
171}
172
173// isBinaryContent detects if file content is binary using a simple heuristic.
174// Returns true if content contains null bytes in the first 8KB.
175func isBinaryContent(content []byte) bool {
176 // Check first 8KB for null bytes (Git's detection method)
177 size := len(content)
178 if size > 8000 {
179 size = 8000
180 }
181 for i := 0; i < size; i++ {
182 if content[i] == 0 {
183 return true
184 }
185 }
186 return false
187}
188
189// isMarkdownFile checks if a file has a markdown extension.
190func isMarkdownFile(path string) bool {
191 ext := strings.ToLower(filepath.Ext(path))
192 return ext == ".md" || ext == ".markdown"
193}
194
195// highlightCode applies syntax highlighting to code and returns HTML.
196func highlightCode(path string, content []byte) template.HTML {
197 lexer := lexers.Match(path)
198 if lexer == nil {
199 lexer = lexers.Analyse(string(content))
200 }
201 if lexer == nil {
202 lexer = lexers.Fallback
203 }
204
205 style := styles.Get("github")
206 if style == nil {
207 style = styles.Fallback
208 }
209
210 formatter := html.New(
211 html.WithClasses(true),
212 html.WithLineNumbers(true),
213 html.WithLinkableLineNumbers(true, ""),
214 )
215
216 iterator, err := lexer.Tokenise(nil, string(content))
217 if err != nil {
218 return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
219 }
220
221 var buf bytes.Buffer
222 err = formatter.Format(&buf, style, iterator)
223 if err != nil {
224 return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
225 }
226
227 return template.HTML(buf.String())
228}