repos_files.go
⎇
Raw
1package web
2
3import (
4 "errors"
5 "io"
6 "mime"
7 "net/http"
8 "os/exec"
9 "path"
10 "slices"
11 "strconv"
12 "strings"
13
14 "github.com/gabriel-vasile/mimetype"
15
16 "hearthforge/internal/gitcmd"
17 "hearthforge/internal/highlight"
18 "hearthforge/internal/markdown"
19 "hearthforge/internal/util"
20 "hearthforge/internal/web/views"
21)
22
23// rawInertTypes are served as plain text from /raw. An HTML or SVG document
24// on our own origin could load another raw file as a same-origin script, which
25// is a stored-XSS path. Every other type keeps its real MIME so previews work.
26var rawInertTypes = map[string]bool{
27 "text/html": true,
28 "application/xhtml+xml": true,
29 "image/svg+xml": true,
30}
31
32func rawServeContentType(contentType string) string {
33 base := strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
34 if rawInertTypes[base] {
35 return "text/plain; charset=utf-8"
36 }
37 return contentType
38}
39
40// treeRoot lists the repository root at a ref.
41func (s *Server) treeRoot(w http.ResponseWriter, r *http.Request) {
42 s.renderTree(w, r, "")
43}
44
45// treePath lists a subdirectory, or redirects to the blob view for a file.
46func (s *Server) treePath(w http.ResponseWriter, r *http.Request) {
47 s.renderTree(w, r, refParam(r, "*"))
48}
49
50func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, subpath string) {
51 repo, ok := s.visibleRepo(w, r)
52 if !ok {
53 return
54 }
55 ref := refParam(r, "ref")
56 resolved, err := s.Git.ResolveRef(r.Context(), repo.Name, ref)
57 if err != nil {
58 http.Error(w, "Not found", http.StatusNotFound)
59 return
60 }
61 entries, err := s.Git.LsTree(r.Context(), repo.Name, ref, subpath)
62 if err != nil {
63 http.Error(w, "Not found", gitStatusCode(err))
64 return
65 }
66 if subpath != "" && len(entries) == 0 {
67 // An empty listing means the path is a file, not a directory.
68 redirectTo(w, r, "/"+repo.Name+"/blob/"+views.EscapePath(ref)+"/"+views.EscapePath(subpath))
69 return
70 }
71 branches, _ := s.Git.Branches(r.Context(), repo.Name)
72 tags, _ := s.Git.Tags(r.Context(), repo.Name)
73
74 readmeHTML, readmePath := "", ""
75 if content, p := s.readme(r, repo.Name, ref, subpath, entries); p != "" {
76 readmeHTML = s.renderReadme(content, repo.Name, ref, subpath, resolved)
77 readmePath = p
78 }
79 views.Render(w, http.StatusOK, views.FileTree(s.Cfg, User(r), repo, ref, subpath,
80 entries, branches, tags, readmeHTML, readmePath))
81}
82
83func (s *Server) blobView(w http.ResponseWriter, r *http.Request) {
84 repo, ok := s.visibleRepo(w, r)
85 if !ok {
86 return
87 }
88 ref := refParam(r, "ref")
89 filePath := refParam(r, "*")
90 filename := path.Base(filePath)
91 blobError := r.URL.Query().Get("error")
92
93 // Check the size before reading the blob. Holding a huge buffer and then
94 // highlighting it is the cheapest denial-of-service against a public repo.
95 size, sizeErr := s.Git.FileSize(r.Context(), repo.Name, ref, filePath)
96 if sizeErr == nil && size > s.Cfg.MaxRenderBytes {
97 branches, _ := s.Git.Branches(r.Context(), repo.Name)
98 tags, _ := s.Git.Tags(r.Context(), repo.Name)
99 views.Render(w, http.StatusOK, views.FileBlob(s.Cfg, User(r), repo, ref, filePath,
100 highlight.FileView{Type: "download", Size: size}, branches, tags, "", blobError))
101 return
102 }
103
104 content, err := s.Git.Show(r.Context(), repo.Name, ref, filePath)
105 if err != nil {
106 http.Error(w, "Not found", http.StatusNotFound)
107 return
108 }
109 commitSHA, err := s.Git.ResolveRef(r.Context(), repo.Name, ref)
110 if err != nil {
111 http.Error(w, "Not found", http.StatusNotFound)
112 return
113 }
114 branches, _ := s.Git.Branches(r.Context(), repo.Name)
115 tags, _ := s.Git.Tags(r.Context(), repo.Name)
116
117 cacheKey := repo.Name + ":" + commitSHA + ":" + filePath
118 view := s.HL.ServeFile(content, filename, cacheKey)
119
120 markdownHTML := ""
121 if markdownExt.MatchString(filename) {
122 dir := path.Dir(filePath)
123 if dir == "." {
124 dir = ""
125 }
126 markdownHTML = s.MD.Render(string(content), cacheKey,
127 &markdown.Context{Repo: repo.Name, Ref: ref, Dir: dir})
128 }
129 views.Render(w, http.StatusOK, views.FileBlob(s.Cfg, User(r), repo, ref, filePath,
130 view, branches, tags, markdownHTML, blobError))
131}
132
133// rawFile streams a blob straight from git. It never buffers the whole file.
134func (s *Server) rawFile(w http.ResponseWriter, r *http.Request) {
135 repo, ok := s.visibleRepo(w, r)
136 if !ok {
137 return
138 }
139 ref := refParam(r, "ref")
140 filePath := refParam(r, "*")
141 total, err := s.Git.FileSize(r.Context(), repo.Name, ref, filePath)
142 if err != nil {
143 http.Error(w, "Not found", http.StatusNotFound)
144 return
145 }
146 if s.Cfg.MaxRawDownloadBytes > 0 && total > s.Cfg.MaxRawDownloadBytes {
147 http.Error(w, "File exceeds raw download size limit", http.StatusRequestEntityTooLarge)
148 return
149 }
150 filename := path.Base(filePath)
151
152 head, err := s.blobHead(r, repo.Name, ref, filePath)
153 if err != nil {
154 http.Error(w, "Not found", http.StatusNotFound)
155 return
156 }
157 contentType := rawServeContentType(sniffContentType(filename, head))
158
159 body, stop, err := s.blobStream(r, repo.Name, ref, filePath)
160 if err != nil {
161 http.Error(w, "Not found", http.StatusNotFound)
162 return
163 }
164 defer stop()
165
166 start, length, partial := parseRange(r.Header.Get("Range"), total)
167 h := w.Header()
168 h.Set("Content-Type", contentType)
169 h.Set("Accept-Ranges", "bytes")
170 if partial {
171 h.Set("Content-Range", "bytes "+strconv.FormatInt(start, 10)+"-"+
172 strconv.FormatInt(start+length-1, 10)+"/"+strconv.FormatInt(total, 10))
173 h.Set("Content-Length", strconv.FormatInt(length, 10))
174 w.WriteHeader(http.StatusPartialContent)
175 if start > 0 {
176 if _, err := io.CopyN(io.Discard, body, start); err != nil {
177 return
178 }
179 }
180 _, _ = io.CopyN(w, body, length)
181 return
182 }
183 h.Set("Content-Disposition", util.ContentDisposition("inline", filename))
184 h.Set("Content-Length", strconv.FormatInt(total, 10))
185 _, _ = io.Copy(w, body)
186}
187
188// blobStream starts `git cat-file blob` and returns its stdout. The returned
189// stop function kills git, which matters when a client disconnects early.
190// cat-file is used over `git show` so the streamed bytes match the size
191// cat-file -s reported, even on repos with smudge filters.
192func (s *Server) blobStream(r *http.Request, repoName, ref, filePath string) (io.Reader, func(), error) {
193 if !gitcmd.ValidRef(ref) || !gitcmd.ValidPath(filePath) {
194 return nil, nil, gitcmd.ErrInvalidRef
195 }
196 cmd := exec.CommandContext(r.Context(), "git", "-C", s.Git.RepoPath(repoName),
197 "cat-file", "blob", ref+":"+filePath)
198 cmd.Env = gitcmd.Env()
199 out, err := cmd.StdoutPipe()
200 if err != nil {
201 return nil, nil, err
202 }
203 if err := cmd.Start(); err != nil {
204 return nil, nil, err
205 }
206 stop := func() {
207 _ = out.Close()
208 _ = cmd.Process.Kill()
209 _ = cmd.Wait()
210 }
211 return out, stop, nil
212}
213
214// blobHead reads the first bytes of a blob for content sniffing.
215func (s *Server) blobHead(r *http.Request, repoName, ref, filePath string) ([]byte, error) {
216 body, stop, err := s.blobStream(r, repoName, ref, filePath)
217 if err != nil {
218 return nil, err
219 }
220 defer stop()
221 head := make([]byte, highlight.BinaryDetectBytes)
222 n, err := io.ReadFull(body, head)
223 if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
224 return nil, err
225 }
226 return head[:n], nil
227}
228
229// sniffContentType prefers the magic bytes, then the file extension, and
230// falls back to a binary/text split.
231func sniffContentType(filename string, head []byte) string {
232 detected := mimetype.Detect(head).String()
233 generic := strings.HasPrefix(detected, "text/plain") || detected == "application/octet-stream"
234 if !generic {
235 return detected
236 }
237 if byExt := mime.TypeByExtension(path.Ext(filename)); byExt != "" {
238 return byExt
239 }
240 if highlight.HasBinaryContent(head) {
241 return "application/octet-stream"
242 }
243 return "text/plain; charset=utf-8"
244}
245
246// parseRange reads a single `bytes=a-b` range. It reports partial=false when
247// the header is absent or unusable, which serves the whole file.
248func parseRange(header string, total int64) (start, length int64, partial bool) {
249 spec, ok := strings.CutPrefix(header, "bytes=")
250 if !ok || total == 0 {
251 return 0, 0, false
252 }
253 from, to, ok := strings.Cut(spec, "-")
254 if !ok {
255 return 0, 0, false
256 }
257 start = 0
258 if from != "" {
259 n, err := strconv.ParseInt(from, 10, 64)
260 if err != nil || n < 0 || n >= total {
261 return 0, 0, false
262 }
263 start = n
264 }
265 end := total - 1
266 if to != "" {
267 n, err := strconv.ParseInt(to, 10, 64)
268 if err != nil {
269 return 0, 0, false
270 }
271 end = min(n, total-1)
272 }
273 if end < start {
274 return 0, 0, false
275 }
276 return start, end - start + 1, true
277}
278
279func (s *Server) editFilePage(w http.ResponseWriter, r *http.Request) {
280 repo, ok := s.adminRepo(w, r)
281 if !ok {
282 return
283 }
284 ref := refParam(r, "ref")
285 filePath := refParam(r, "*")
286 branches, _ := s.Git.Branches(r.Context(), repo.Name)
287 if !slices.Contains(branches, ref) {
288 http.Error(w, "Not found", http.StatusNotFound)
289 return
290 }
291 content, err := s.Git.Show(r.Context(), repo.Name, ref, filePath)
292 if err != nil || len(content) == 0 {
293 http.Error(w, "Not found", http.StatusNotFound)
294 return
295 }
296 if highlight.HasBinaryContent(content) {
297 http.Error(w, "Not found", http.StatusNotFound)
298 return
299 }
300 views.Render(w, http.StatusOK, views.FileEdit(s.Cfg, User(r), repo, ref, filePath,
301 string(content), r.URL.Query().Get("error")))
302}
303
304func (s *Server) editFile(w http.ResponseWriter, r *http.Request) {
305 repo, ok := s.adminRepo(w, r)
306 if !ok {
307 return
308 }
309 ref := refParam(r, "ref")
310 filePath := refParam(r, "*")
311 branches, _ := s.Git.Branches(r.Context(), repo.Name)
312 if !slices.Contains(branches, ref) {
313 http.Error(w, "Not found", http.StatusNotFound)
314 return
315 }
316 back := "/" + repo.Name + "/edit/" + views.EscapePath(ref) + "/" + views.EscapePath(filePath)
317
318 newPath := strings.TrimSpace(r.FormValue("new_path"))
319 targetPath := filePath
320 if newPath != "" && newPath != filePath {
321 if len(newPath) > maxFilePathBytes || !gitcmd.ValidPath(newPath) {
322 s.backTo(w, r, back, "error", "Invalid file path.")
323 return
324 }
325 targetPath = newPath
326 }
327
328 message := strings.TrimSpace(r.FormValue("message"))
329 if message == "" {
330 if targetPath != filePath {
331 message = "Rename " + path.Base(filePath) + " to " + path.Base(targetPath)
332 } else {
333 message = "Edited " + path.Base(filePath)
334 }
335 }
336 content := strings.ReplaceAll(r.FormValue("content"), "\r\n", "\n")
337
338 commit, err := s.Git.EditFile(r.Context(), repo.Name, ref, filePath, targetPath,
339 []byte(content), message, s.committer())
340 if err != nil {
341 http.Error(w, "Failed to save file", gitStatusCode(err))
342 return
343 }
344 redirectTo(w, r, "/"+repo.Name+"/commit/"+commit)
345}
346
347func (s *Server) newFilePage(w http.ResponseWriter, r *http.Request) {
348 repo, ok := s.adminRepo(w, r)
349 if !ok {
350 return
351 }
352 q := r.URL.Query()
353 views.Render(w, http.StatusOK, views.NewFileForm(s.Cfg, User(r), repo,
354 refParam(r, "ref"), q.Get("dir"), q.Get("error")))
355}
356
357func (s *Server) createFile(w http.ResponseWriter, r *http.Request) {
358 repo, ok := s.adminRepo(w, r)
359 if !ok {
360 return
361 }
362 ref := refParam(r, "ref")
363 back := "/" + repo.Name + "/new-file/" + views.EscapePath(ref)
364
365 filePath := strings.TrimSpace(r.FormValue("path"))
366 if len(filePath) > maxFilePathBytes || !gitcmd.ValidPath(filePath) {
367 s.backTo(w, r, back, "error", "Invalid file path.")
368 return
369 }
370 message := strings.TrimSpace(r.FormValue("message"))
371 if message == "" {
372 message = "Add " + filePath
373 }
374
375 branches, _ := s.Git.Branches(r.Context(), repo.Name)
376 if len(branches) > 0 && !slices.Contains(branches, ref) {
377 s.backTo(w, r, back, "error", "Can only create files on a branch.")
378 return
379 }
380 commit, err := s.Git.EditFile(r.Context(), repo.Name, ref, "", filePath,
381 []byte(r.FormValue("content")), message, s.committer())
382 if err != nil {
383 s.backTo(w, r, back, "error", writeFailMessage(err, "Failed to create file."))
384 return
385 }
386 redirectTo(w, r, "/"+repo.Name+"/commit/"+commit)
387}
388
389func (s *Server) deleteFile(w http.ResponseWriter, r *http.Request) {
390 repo, ok := s.adminRepo(w, r)
391 if !ok {
392 return
393 }
394 ref := refParam(r, "ref")
395 filePath := refParam(r, "*")
396 message := strings.TrimSpace(r.FormValue("message"))
397 if message == "" {
398 message = "Delete " + filePath
399 }
400 commit, err := s.Git.DeleteFile(r.Context(), repo.Name, ref, filePath, message, s.committer())
401 if err != nil {
402 s.backTo(w, r, "/"+repo.Name+"/blob/"+views.EscapePath(ref)+"/"+views.EscapePath(filePath), "error",
403 writeFailMessage(err, "Failed to delete file."))
404 return
405 }
406 redirectTo(w, r, "/"+repo.Name+"/commit/"+commit)
407}
408
409// writeFailMessage names the concurrent-update case, which the user can fix
410// by reloading. Everything else keeps the generic message.
411func writeFailMessage(err error, generic string) string {
412 if errors.Is(err, gitcmd.ErrRefChanged) {
413 return "The branch moved while saving. Please reload and try again."
414 }
415 return generic
416}
417
418// committer is the identity used for commits made through the web UI.
419func (s *Server) committer() gitcmd.Ident {
420 return gitcmd.Ident{Name: s.Cfg.CommitterName, Email: s.Cfg.CommitterEmail}
421}
422