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