package web import ( "errors" "io" "mime" "net/http" "os/exec" "path" "slices" "strconv" "strings" "github.com/gabriel-vasile/mimetype" "hearthforge/internal/db" "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) readme := s.readme(r, repo.Name, ref, subpath, resolved, entries) views.Render(w, http.StatusOK, views.FileTree(s.Cfg, User(r), repo, ref, subpath, entries, branches, tags, readme)) } func (s *Server) blobView(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } s.renderBlob(w, r, repo, http.StatusOK, r.URL.Query().Get("error")) } // renderBlob shows the file at the ref and path of the request URL. func (s *Server) renderBlob(w http.ResponseWriter, r *http.Request, repo *db.Repo, status int, blobError string) { ref := refParam(r, "ref") filePath := refParam(r, "*") filename := path.Base(filePath) // Everything below reads at one commit, which the delete form sends as its base. commitSHA, err := s.Git.ResolveRef(r.Context(), repo.Name, ref) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } // 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, commitSHA, 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, status, views.FileBlob(s.Cfg, User(r), repo, ref, filePath, commitSHA, highlight.FileView{Type: "download", Size: size}, branches, tags, "", blobError)) return } content, err := s.Git.Show(r.Context(), repo.Name, commitSHA, filePath) 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, status, views.FileBlob(s.Cfg, User(r), repo, ref, filePath, commitSHA, 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 one `bytes=a-b` or `bytes=-n` range. partial is false // when the header is absent or unusable. 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 } if from == "" { n, err := strconv.ParseInt(to, 10, 64) if err != nil || n <= 0 { return 0, 0, false } n = min(n, total) return total - n, n, true } start, err := strconv.ParseInt(from, 10, 64) if err != nil || start < 0 || start >= total { return 0, 0, false } 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 } base, err := s.Git.ResolveRef(r.Context(), repo.Name, ref) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } content, err := s.Git.Show(r.Context(), repo.Name, base, 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, views.FileForm{ Content: string(content), Base: base, Error: 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") base := r.FormValue("base") commit, err := s.Git.EditFile(r.Context(), repo.Name, ref, base, filePath, targetPath, []byte(content), message, s.committer()) if err != nil { form := views.FileForm{Path: targetPath, Content: content, Message: message, Base: base} var stale *gitcmd.StaleError switch { case errors.As(err, &stale): form.Base, form.Stale = stale.Tip, true form.Error = "This file changed on " + ref + " since you opened it. " + "Saving again overwrites that change." case errors.Is(err, gitcmd.ErrExists): form.Error = "A file already exists at " + targetPath + "." default: http.Error(w, "Failed to save file", gitStatusCode(err)) return } views.Render(w, http.StatusConflict, views.FileEdit(s.Cfg, User(r), repo, ref, filePath, form)) 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"), views.FileForm{Error: 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 } userMessage := strings.TrimSpace(r.FormValue("message")) message := userMessage 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 } content := r.FormValue("content") commit, err := s.Git.EditFile(r.Context(), repo.Name, ref, "", "", filePath, []byte(content), message, s.committer()) if errors.Is(err, gitcmd.ErrExists) { views.Render(w, http.StatusConflict, views.NewFileForm(s.Cfg, User(r), repo, ref, "", views.FileForm{ Path: filePath, Content: content, Message: userMessage, Error: "A file already exists at " + filePath + ".", })) return } 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, r.FormValue("base"), filePath, message, s.committer()) if errors.Is(err, gitcmd.ErrNotFound) { http.Error(w, "No such file on this branch", http.StatusNotFound) return } var stale *gitcmd.StaleError if errors.As(err, &stale) { s.renderBlob(w, r, repo, http.StatusConflict, "This file changed on "+ref+ " since you opened it. The current version is shown below. Delete again to remove it.") return } 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} }