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