package web import ( "errors" "io" "mime" "net/http" "os/exec" "path" "slices" "strconv" "strings" "github.com/gabriel-vasile/mimetype" "hearthforge/internal/gitcmd" "hearthforge/internal/highlight" "hearthforge/internal/markdown" "hearthforge/internal/util" "hearthforge/internal/web/views" ) // rawSandboxCSP is sent with every /raw response the browser would parse as a // document (HTML, SVG, XML). `sandbox` without allow-same-origin gives the // document an opaque origin: no cookies, no storage, and no readable fetch // of anything on this server. No allow-scripts, so nothing runs at all. // A raw file on our origin could otherwise act as the viewer, which is a // stored-XSS path. Every other type gets no policy, so previews work. const rawSandboxCSP = "sandbox; default-src 'none'; img-src 'self' data:; " + "style-src 'unsafe-inline'; font-src 'self' data:; frame-ancestors 'none'" // isDocumentType reports whether a browser parses this MIME type as a // scripting document. It reads the declared type, the same value that goes // into Content-Type, so the render and sandbox decisions cannot drift apart. func isDocumentType(contentType string) bool { base := strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0])) switch base { case "text/html", "application/xhtml+xml", "image/svg+xml", "text/xml", "application/xml": return true } return strings.HasSuffix(base, "+xml") } // treeRoot lists the repository root at a ref. func (s *Server) treeRoot(w http.ResponseWriter, r *http.Request) { s.renderTree(w, r, "") } // treePath lists a subdirectory, or redirects to the blob view for a file. func (s *Server) treePath(w http.ResponseWriter, r *http.Request) { s.renderTree(w, r, refParam(r, "*")) } func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, subpath string) { repo, ok := s.visibleRepo(w, r) if !ok { return } ref := refParam(r, "ref") resolved, err := s.Git.ResolveRef(r.Context(), repo.Name, ref) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } entries, err := s.Git.LsTree(r.Context(), repo.Name, ref, subpath) if err != nil { http.Error(w, "Not found", gitStatusCode(err)) return } if subpath != "" && len(entries) == 0 { // An empty listing means the path is a file, not a directory. redirectTo(w, r, "/"+repo.Name+"/blob/"+views.EscapePath(ref)+"/"+views.EscapePath(subpath)) return } branches, _ := s.Git.Branches(r.Context(), repo.Name) tags, _ := s.Git.Tags(r.Context(), repo.Name) readmeHTML, readmePath := "", "" if content, p := s.readme(r, repo.Name, ref, subpath, entries); p != "" { readmeHTML = s.renderReadme(content, repo.Name, ref, subpath, resolved) readmePath = p } views.Render(w, http.StatusOK, views.FileTree(s.Cfg, User(r), repo, ref, subpath, entries, branches, tags, readmeHTML, readmePath)) } func (s *Server) blobView(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } ref := refParam(r, "ref") filePath := refParam(r, "*") filename := path.Base(filePath) blobError := r.URL.Query().Get("error") // Check the size before reading the blob. Holding a huge buffer and then // highlighting it is the cheapest denial-of-service against a public repo. size, sizeErr := s.Git.FileSize(r.Context(), repo.Name, ref, filePath) if sizeErr == nil && size > s.Cfg.MaxRenderBytes { branches, _ := s.Git.Branches(r.Context(), repo.Name) tags, _ := s.Git.Tags(r.Context(), repo.Name) views.Render(w, http.StatusOK, views.FileBlob(s.Cfg, User(r), repo, ref, filePath, highlight.FileView{Type: "download", Size: size}, branches, tags, "", blobError)) return } content, err := s.Git.Show(r.Context(), repo.Name, ref, filePath) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } commitSHA, err := s.Git.ResolveRef(r.Context(), repo.Name, ref) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } branches, _ := s.Git.Branches(r.Context(), repo.Name) tags, _ := s.Git.Tags(r.Context(), repo.Name) cacheKey := repo.Name + ":" + commitSHA + ":" + filePath view := s.HL.ServeFile(content, filename, cacheKey) markdownHTML := "" if markdownExt.MatchString(filename) { dir := path.Dir(filePath) if dir == "." { dir = "" } markdownHTML = s.MD.Render(string(content), cacheKey, &markdown.Context{Repo: repo.Name, Ref: ref, Dir: dir}) } views.Render(w, http.StatusOK, views.FileBlob(s.Cfg, User(r), repo, ref, filePath, view, branches, tags, markdownHTML, blobError)) } // rawFile streams a blob straight from git. It never buffers the whole file. func (s *Server) rawFile(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } ref := refParam(r, "ref") filePath := refParam(r, "*") total, err := s.Git.FileSize(r.Context(), repo.Name, ref, filePath) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } if s.Cfg.MaxRawDownloadBytes > 0 && total > s.Cfg.MaxRawDownloadBytes { http.Error(w, "File exceeds raw download size limit", http.StatusRequestEntityTooLarge) return } filename := path.Base(filePath) head, err := s.blobHead(r, repo.Name, ref, filePath) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } contentType := sniffContentType(filename, head) body, stop, err := s.blobStream(r, repo.Name, ref, filePath) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } defer stop() start, length, partial := parseRange(r.Header.Get("Range"), total) h := w.Header() h.Set("Content-Type", contentType) h.Set("Accept-Ranges", "bytes") if isDocumentType(contentType) { h.Set("Content-Security-Policy", rawSandboxCSP) } if partial { h.Set("Content-Range", "bytes "+strconv.FormatInt(start, 10)+"-"+ strconv.FormatInt(start+length-1, 10)+"/"+strconv.FormatInt(total, 10)) h.Set("Content-Length", strconv.FormatInt(length, 10)) w.WriteHeader(http.StatusPartialContent) if start > 0 { if _, err := io.CopyN(io.Discard, body, start); err != nil { return } } _, _ = io.CopyN(w, body, length) return } h.Set("Content-Disposition", util.ContentDisposition("inline", filename)) h.Set("Content-Length", strconv.FormatInt(total, 10)) _, _ = io.Copy(w, body) } // blobStream starts `git cat-file blob` and returns its stdout. The returned // stop function kills git, which matters when a client disconnects early. // cat-file is used over `git show` so the streamed bytes match the size // cat-file -s reported, even on repos with smudge filters. func (s *Server) blobStream(r *http.Request, repoName, ref, filePath string) (io.Reader, func(), error) { if !gitcmd.ValidRef(ref) || !gitcmd.ValidPath(filePath) { return nil, nil, gitcmd.ErrInvalidRef } cmd := exec.CommandContext(r.Context(), "git", "-C", s.Git.RepoPath(repoName), "cat-file", "blob", ref+":"+filePath) cmd.Env = gitcmd.Env() out, err := cmd.StdoutPipe() if err != nil { return nil, nil, err } if err := cmd.Start(); err != nil { return nil, nil, err } stop := func() { _ = out.Close() _ = cmd.Process.Kill() _ = cmd.Wait() } return out, stop, nil } // blobHead reads the first bytes of a blob for content sniffing. func (s *Server) blobHead(r *http.Request, repoName, ref, filePath string) ([]byte, error) { body, stop, err := s.blobStream(r, repoName, ref, filePath) if err != nil { return nil, err } defer stop() head := make([]byte, highlight.BinaryDetectBytes) n, err := io.ReadFull(body, head) if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { return nil, err } return head[:n], nil } // sniffContentType prefers the magic bytes, then the file extension, and // falls back to a binary/text split. func sniffContentType(filename string, head []byte) string { detected := mimetype.Detect(head).String() generic := strings.HasPrefix(detected, "text/plain") || detected == "application/octet-stream" if !generic { return detected } if byExt := mime.TypeByExtension(path.Ext(filename)); byExt != "" { return byExt } if highlight.HasBinaryContent(head) { return "application/octet-stream" } return "text/plain; charset=utf-8" } // parseRange reads a single `bytes=a-b` range. It reports partial=false when // the header is absent or unusable, which serves the whole file. func parseRange(header string, total int64) (start, length int64, partial bool) { spec, ok := strings.CutPrefix(header, "bytes=") if !ok || total == 0 { return 0, 0, false } from, to, ok := strings.Cut(spec, "-") if !ok { return 0, 0, false } start = 0 if from != "" { n, err := strconv.ParseInt(from, 10, 64) if err != nil || n < 0 || n >= total { return 0, 0, false } start = n } end := total - 1 if to != "" { n, err := strconv.ParseInt(to, 10, 64) if err != nil { return 0, 0, false } end = min(n, total-1) } if end < start { return 0, 0, false } return start, end - start + 1, true } func (s *Server) editFilePage(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } ref := refParam(r, "ref") filePath := refParam(r, "*") branches, _ := s.Git.Branches(r.Context(), repo.Name) if !slices.Contains(branches, ref) { http.Error(w, "Not found", http.StatusNotFound) return } content, err := s.Git.Show(r.Context(), repo.Name, ref, filePath) if err != nil || len(content) == 0 { http.Error(w, "Not found", http.StatusNotFound) return } if highlight.HasBinaryContent(content) { http.Error(w, "Not found", http.StatusNotFound) return } views.Render(w, http.StatusOK, views.FileEdit(s.Cfg, User(r), repo, ref, filePath, string(content), r.URL.Query().Get("error"))) } func (s *Server) editFile(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } ref := refParam(r, "ref") filePath := refParam(r, "*") branches, _ := s.Git.Branches(r.Context(), repo.Name) if !slices.Contains(branches, ref) { http.Error(w, "Not found", http.StatusNotFound) return } back := "/" + repo.Name + "/edit/" + views.EscapePath(ref) + "/" + views.EscapePath(filePath) newPath := strings.TrimSpace(r.FormValue("new_path")) targetPath := filePath if newPath != "" && newPath != filePath { if len(newPath) > maxFilePathBytes || !gitcmd.ValidPath(newPath) { s.backTo(w, r, back, "error", "Invalid file path.") return } targetPath = newPath } message := strings.TrimSpace(r.FormValue("message")) if message == "" { if targetPath != filePath { message = "Rename " + path.Base(filePath) + " to " + path.Base(targetPath) } else { message = "Edited " + path.Base(filePath) } } content := strings.ReplaceAll(r.FormValue("content"), "\r\n", "\n") commit, err := s.Git.EditFile(r.Context(), repo.Name, ref, filePath, targetPath, []byte(content), message, s.committer()) if err != nil { http.Error(w, "Failed to save file", gitStatusCode(err)) return } redirectTo(w, r, "/"+repo.Name+"/commit/"+commit) } func (s *Server) newFilePage(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } q := r.URL.Query() views.Render(w, http.StatusOK, views.NewFileForm(s.Cfg, User(r), repo, refParam(r, "ref"), q.Get("dir"), q.Get("error"))) } func (s *Server) createFile(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } ref := refParam(r, "ref") back := "/" + repo.Name + "/new-file/" + views.EscapePath(ref) filePath := strings.TrimSpace(r.FormValue("path")) if len(filePath) > maxFilePathBytes || !gitcmd.ValidPath(filePath) { s.backTo(w, r, back, "error", "Invalid file path.") return } message := strings.TrimSpace(r.FormValue("message")) if message == "" { message = "Add " + filePath } branches, _ := s.Git.Branches(r.Context(), repo.Name) if len(branches) > 0 && !slices.Contains(branches, ref) { s.backTo(w, r, back, "error", "Can only create files on a branch.") return } commit, err := s.Git.EditFile(r.Context(), repo.Name, ref, "", filePath, []byte(r.FormValue("content")), message, s.committer()) if err != nil { s.backTo(w, r, back, "error", writeFailMessage(err, "Failed to create file.")) return } redirectTo(w, r, "/"+repo.Name+"/commit/"+commit) } func (s *Server) deleteFile(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } ref := refParam(r, "ref") filePath := refParam(r, "*") message := strings.TrimSpace(r.FormValue("message")) if message == "" { message = "Delete " + filePath } commit, err := s.Git.DeleteFile(r.Context(), repo.Name, ref, filePath, message, s.committer()) if err != nil { s.backTo(w, r, "/"+repo.Name+"/blob/"+views.EscapePath(ref)+"/"+views.EscapePath(filePath), "error", writeFailMessage(err, "Failed to delete file.")) return } redirectTo(w, r, "/"+repo.Name+"/commit/"+commit) } // writeFailMessage names the concurrent-update case, which the user can fix // by reloading. Everything else keeps the generic message. func writeFailMessage(err error, generic string) string { if errors.Is(err, gitcmd.ErrRefChanged) { return "The branch moved while saving. Please reload and try again." } return generic } // committer is the identity used for commits made through the web UI. func (s *Server) committer() gitcmd.Ident { return gitcmd.Ident{Name: s.Cfg.CommitterName, Email: s.Cfg.CommitterEmail} }