Files
byre/internal/server/files.go
T

257 lines
5.5 KiB
Go

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)
}