package server import ( "bytes" "errors" "fmt" "io" "io/fs" "mime" "net/http" "os" "path" "path/filepath" "slices" "strings" "unicode/utf8" "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/git" ) const ( maxFileBytes = 256 << 10 maxDirEntries = 5000 ) func (s *Server) GetTaskDir(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskDirParams) { t, ok := s.activeTask(w, r, taskID) if !ok { return } rel := "" if params.Path != nil { rel = *params.Path } name, full := taskPath(t.WorktreePath, rel) f, err := os.Open(full) if err != nil { writePathError(w, err) return } defer f.Close() fi, err := f.Stat() if err != nil { writeError(w, http.StatusInternalServerError, err) return } if !fi.IsDir() { writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a directory", rel)) return } des, err := f.ReadDir(-1) if err != nil { writeError(w, http.StatusInternalServerError, err) return } entries := []api.DirEntry{} for _, de := range des { if de.Name() == ".git" { continue } entries = append(entries, dirEntry(filepath.Join(full, de.Name()), de)) } slices.SortFunc(entries, func(a, b api.DirEntry) int { if (a.Type == api.DirEntryTypeDir) != (b.Type == api.DirEntryTypeDir) { if a.Type == api.DirEntryTypeDir { return -1 } return 1 } return strings.Compare(a.Name, b.Name) }) listing := api.DirListing{Entries: entries} if name != "." { listing.Path = name } if len(entries) > maxDirEntries { listing.Entries = entries[:maxDirEntries] listing.Truncated = true } real, err := realDir(t.WorktreePath, full) if err != nil { writeError(w, http.StatusInternalServerError, err) return } if !filepath.IsLocal(real) { writeJSON(w, http.StatusOK, listing) return } paths := []string{} for _, e := range listing.Entries { paths = append(paths, path.Join(real, e.Name)) } ignored, err := git.Ignored(r.Context(), t.WorktreePath, paths) if err != nil { writeError(w, http.StatusInternalServerError, err) return } for i, p := range paths { listing.Entries[i].Ignored = ignored[p] } writeJSON(w, http.StatusOK, listing) } func realDir(worktree, full string) (string, error) { root, err := filepath.EvalSymlinks(worktree) if err != nil { return "", err } dir, err := filepath.EvalSymlinks(full) if err != nil { return "", err } rel, err := filepath.Rel(root, dir) if err != nil { return "", err } return filepath.ToSlash(rel), nil } func dirEntry(full string, de fs.DirEntry) api.DirEntry { e := api.DirEntry{Name: de.Name(), Type: api.DirEntryTypeOther} var fi fs.FileInfo var err error if de.Type()&fs.ModeSymlink != 0 { e.Symlink = true fi, err = os.Stat(full) } else { fi, err = de.Info() } if err != nil { return e } switch { case fi.IsDir(): e.Type = api.DirEntryTypeDir case fi.Mode().IsRegular(): e.Type = api.DirEntryTypeFile e.Size = fi.Size() } return e } func (s *Server) GetTaskFile(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskFileParams) { t, ok := s.activeTask(w, r, taskID) if !ok { return } if params.Path == "" { writeError(w, http.StatusBadRequest, errors.New("path is required")) return } name, full := taskPath(t.WorktreePath, params.Path) fi, err := os.Stat(full) if err != nil { writePathError(w, err) return } if !fi.Mode().IsRegular() { writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a regular file", params.Path)) return } f, err := os.Open(full) if err != nil { writePathError(w, err) return } defer f.Close() b, err := io.ReadAll(io.LimitReader(f, maxFileBytes+1)) if err != nil { writeError(w, http.StatusInternalServerError, err) return } fc := api.FileContent{Path: name, Size: fi.Size()} if len(b) > maxFileBytes { fc.Truncated = true b = b[:maxFileBytes] for n := 0; n < utf8.UTFMax-1 && len(b) > 0 && !utf8.Valid(b); n++ { b = b[:len(b)-1] } } if bytes.IndexByte(b, 0) >= 0 || !utf8.Valid(b) { fc.Binary = true writeJSON(w, http.StatusOK, fc) return } fc.Text = string(b) writeJSON(w, http.StatusOK, fc) } func (s *Server) GetTaskRaw(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskRawParams) { t, ok := s.activeTask(w, r, taskID) if !ok { return } if params.Path == "" { writeError(w, http.StatusBadRequest, errors.New("path is required")) return } name, full := taskPath(t.WorktreePath, params.Path) f, err := os.Open(full) if err != nil { writePathError(w, err) return } defer f.Close() fi, err := f.Stat() if err != nil { writeError(w, http.StatusInternalServerError, err) return } if !fi.Mode().IsRegular() { writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a regular file", params.Path)) return } contentType := mime.TypeByExtension(path.Ext(name)) if contentType == "" { contentType = "application/octet-stream" } w.Header().Set("Content-Type", contentType) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Security-Policy", "sandbox") w.Header().Set("Cache-Control", "private, no-cache") http.ServeContent(w, r, "", fi.ModTime(), f) } func taskPath(worktree, p string) (string, string) { name := filepath.Clean(filepath.FromSlash(p)) if filepath.IsAbs(name) { return filepath.ToSlash(name), name } return filepath.ToSlash(name), filepath.Join(worktree, name) } func writePathError(w http.ResponseWriter, err error) { if errors.Is(err, fs.ErrNotExist) { writeError(w, http.StatusNotFound, err) return } writeError(w, http.StatusBadRequest, err) }