nandi/oripublic Fork 0
3017563d783a895a85f8f8ad162b58bb5d944946
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

files.go · 188 lines · 5.3 KBGo Blame HistoryRaw
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday1// Package files exposes the workspace file API consumed by the SPA's file
2// tree, preview and editor panels: list a directory, search files by name,
3// read a text file, stream a file's raw bytes, write a file back.
4//
5// Access is deliberately unrestricted (absolute paths welcome): ori is meant
6// to run inside an already-isolated sandbox, and the agent itself has the
7// same reach. Relative paths resolve against the configured root (the --cwd
8// given to the agent session).
9package files
10
11import (
12 "encoding/json"
13 "errors"
14 "io/fs"
15 "net/http"
16 "os"
17 "path/filepath"
18 "sort"
19 "unicode/utf8"
20)
21
22// maxFileBytes bounds what the read endpoint returns; larger files are
23// refused rather than truncated, so the editor never silently saves a
24// partial file back.
25const maxFileBytes = 5 << 20 // 5 MiB
26
27// Service answers the file API requests for one workspace root.
28type Service struct {
29 root string
30}
31
32// New creates a Service whose relative paths resolve against root.
33//
34// Example:
35//
36// svc := files.New("/work/my-project")
37// for pattern, handler := range svc.Routes() {
38// mux.Handle(pattern, handler)
39// }
40func New(root string) *Service {
41 return &Service{root: root}
42}
43
44// Routes returns the API endpoints, keyed by http.ServeMux pattern, ready to
45// be merged into the server's route table.
46func (s *Service) Routes() map[string]http.Handler {
47 return map[string]http.Handler{
48 "GET /api/files": http.HandlerFunc(s.listDirectory),
49 "GET /api/files/search": http.HandlerFunc(s.searchFiles),
50 "GET /api/file": http.HandlerFunc(s.readFile),
51 "PUT /api/file": http.HandlerFunc(s.writeFile),
52 "GET /api/raw": http.HandlerFunc(s.rawFile),
53 }
54}
55
56// Entry describes one child of a listed directory.
57type Entry struct {
58 // Name is the base name of the entry.
59 Name string `json:"name"`
60 // Path is the resolved path, directly usable in further API calls.
61 Path string `json:"path"`
62 // IsDir tells directories and files apart.
63 IsDir bool `json:"isDir"`
64 // Size is the file size in bytes (0 for directories).
65 Size int64 `json:"size"`
66}
67
68// resolve turns a request path into the path to operate on: empty means the
69// root, relative paths are joined to it, absolute paths are used as-is.
70func (s *Service) resolve(path string) string {
71 if path == "" {
72 return s.root
73 }
74 if !filepath.IsAbs(path) {
75 return filepath.Join(s.root, path)
76 }
77 return filepath.Clean(path)
78}
79
80func (s *Service) listDirectory(w http.ResponseWriter, r *http.Request) {
81 dir := s.resolve(r.URL.Query().Get("path"))
82 dirEntries, err := os.ReadDir(dir)
83 if err != nil {
84 writeFSError(w, err)
85 return
86 }
87
88 entries := make([]Entry, 0, len(dirEntries))
89 for _, e := range dirEntries {
90 info, infoErr := e.Info()
91 size := int64(0)
92 if infoErr == nil && !e.IsDir() {
93 size = info.Size()
94 }
95 entries = append(entries, Entry{
96 Name: e.Name(),
97 Path: filepath.Join(dir, e.Name()),
98 IsDir: e.IsDir(),
99 Size: size,
100 })
101 }
102 // Directories first, then files, each group alphabetically — the order
103 // the file tree renders in.
104 sort.Slice(entries, func(i, j int) bool {
105 if entries[i].IsDir != entries[j].IsDir {
106 return entries[i].IsDir
107 }
108 return entries[i].Name < entries[j].Name
109 })
110
111 writeJSON(w, http.StatusOK, map[string]any{"path": dir, "entries": entries})
112}
113
114func (s *Service) readFile(w http.ResponseWriter, r *http.Request) {
115 path := s.resolve(r.URL.Query().Get("path"))
116 info, err := os.Stat(path)
117 if err != nil {
118 writeFSError(w, err)
119 return
120 }
121 if info.IsDir() {
122 writeError(w, http.StatusBadRequest, "path is a directory")
123 return
124 }
125 if info.Size() > maxFileBytes {
126 writeError(w, http.StatusRequestEntityTooLarge, "file larger than 5 MiB")
127 return
128 }
129 content, err := os.ReadFile(path)
130 if err != nil {
131 writeFSError(w, err)
132 return
133 }
134 if !utf8.Valid(content) {
135 writeError(w, http.StatusUnsupportedMediaType, "not a text file")
136 return
137 }
138 writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": string(content)})
139}
140
141func (s *Service) writeFile(w http.ResponseWriter, r *http.Request) {
142 path := s.resolve(r.URL.Query().Get("path"))
143 if r.URL.Query().Get("path") == "" {
144 writeError(w, http.StatusBadRequest, "path query parameter is required")
145 return
146 }
147
148 var body struct {
149 Content string `json:"content"`
150 }
151 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxFileBytes)).Decode(&body); err != nil {
152 writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
153 return
154 }
155 if dir := filepath.Dir(path); dir != "" {
156 if err := os.MkdirAll(dir, 0o755); err != nil {
157 writeFSError(w, err)
158 return
159 }
160 }
161 if err := os.WriteFile(path, []byte(body.Content), 0o644); err != nil {
162 writeFSError(w, err)
163 return
164 }
165 writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "saved"})
166}
167
168// writeFSError maps filesystem errors to HTTP statuses.
169func writeFSError(w http.ResponseWriter, err error) {
170 status := http.StatusInternalServerError
171 switch {
172 case errors.Is(err, fs.ErrNotExist):
173 status = http.StatusNotFound
174 case errors.Is(err, fs.ErrPermission):
175 status = http.StatusForbidden
176 }
177 writeError(w, status, err.Error())
178}
179
180func writeError(w http.ResponseWriter, status int, message string) {
181 writeJSON(w, status, map[string]any{"error": message})
182}
183
184func writeJSON(w http.ResponseWriter, status int, payload any) {
185 w.Header().Set("Content-Type", "application/json")
186 w.WriteHeader(status)
187 _ = json.NewEncoder(w).Encode(payload)
188}