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