1package server
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "os"
11 "path/filepath"
12
13 "github.com/charmbracelet/crush/internal/app"
14 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/db"
16 "github.com/charmbracelet/crush/internal/proto"
17)
18
19func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
20 jsonEncode(w, s.cfg)
21}
22
23func (s *Server) handleGetInstances(w http.ResponseWriter, r *http.Request) {
24 instances := []proto.Instance{}
25 for _, ins := range s.instances.Seq2() {
26 instances = append(instances, proto.Instance{
27 ID: ins.ID(),
28 Path: ins.Path(),
29 YOLO: ins.cfg.Permissions != nil && ins.cfg.Permissions.SkipRequests,
30 })
31 }
32 jsonEncode(w, instances)
33}
34
35func (s *Server) handleGetInstanceEvents(w http.ResponseWriter, r *http.Request) {
36 flusher := http.NewResponseController(w)
37 id := r.PathValue("id")
38 ins, ok := s.instances.Get(id)
39 if !ok {
40 s.logError(r, "instance not found", "id", id)
41 jsonError(w, http.StatusNotFound, "instance not found")
42 return
43 }
44
45 w.Header().Set("Content-Type", "text/event-stream")
46 w.Header().Set("Cache-Control", "no-cache")
47 w.Header().Set("Connection", "keep-alive")
48
49 for {
50 select {
51 case <-r.Context().Done():
52 return
53 case ev := <-ins.App.Events():
54 data, err := json.Marshal(ev)
55 if err != nil {
56 s.logError(r, "failed to marshal event", "error", err)
57 continue
58 }
59
60 fmt.Fprintf(w, "data: %s\n\n", data)
61 flusher.Flush()
62 }
63 }
64}
65
66func (s *Server) handleDeleteInstances(w http.ResponseWriter, r *http.Request) {
67 var ids []string
68 id := r.URL.Query().Get("id")
69 if id != "" {
70 ids = append(ids, id)
71 }
72
73 // Get IDs from body
74 var args []proto.Instance
75 if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
76 s.logError(r, "failed to decode request", "error", err)
77 jsonError(w, http.StatusBadRequest, "failed to decode request")
78 return
79 }
80 ids = append(ids, func() []string {
81 out := make([]string, len(args))
82 for i, arg := range args {
83 out[i] = arg.ID
84 }
85 return out
86 }()...)
87
88 for _, id := range ids {
89 s.instances.Del(id)
90 }
91}
92
93func (s *Server) handlePostInstances(w http.ResponseWriter, r *http.Request) {
94 var args proto.Instance
95 if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
96 s.logError(r, "failed to decode request", "error", err)
97 jsonError(w, http.StatusBadRequest, "failed to decode request")
98 return
99 }
100
101 ctx := r.Context()
102 hasher := sha256.New()
103 hasher.Write([]byte(filepath.Clean(args.Path)))
104 id := hex.EncodeToString(hasher.Sum(nil))
105 if existing, ok := s.instances.Get(id); ok {
106 jsonEncode(w, proto.Instance{
107 ID: existing.ID(),
108 Path: existing.Path(),
109 YOLO: existing.cfg.Permissions != nil && existing.cfg.Permissions.SkipRequests,
110 })
111 return
112 }
113
114 cfg, err := config.Init(args.Path, s.cfg.Options.DataDirectory, s.cfg.Options.Debug)
115 if err != nil {
116 s.logError(r, "failed to initialize config", "error", err)
117 jsonError(w, http.StatusBadRequest, fmt.Sprintf("failed to initialize config: %v", err))
118 return
119 }
120
121 if cfg.Permissions == nil {
122 cfg.Permissions = &config.Permissions{}
123 }
124 cfg.Permissions.SkipRequests = args.YOLO
125
126 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
127 s.logError(r, "failed to create data directory", "error", err)
128 jsonError(w, http.StatusInternalServerError, "failed to create data directory")
129 return
130 }
131
132 // Connect to DB; this will also run migrations.
133 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
134 if err != nil {
135 s.logError(r, "failed to connect to database", "error", err)
136 jsonError(w, http.StatusInternalServerError, "failed to connect to database")
137 return
138 }
139
140 appInstance, err := app.New(ctx, conn, cfg)
141 if err != nil {
142 slog.Error("failed to create app instance", "error", err)
143 jsonError(w, http.StatusInternalServerError, "failed to create app instance")
144 return
145 }
146
147 ins := &Instance{
148 App: appInstance,
149 State: InstanceStateCreated,
150 id: id,
151 path: args.Path,
152 cfg: cfg,
153 }
154
155 s.instances.Set(id, ins)
156 jsonEncode(w, proto.Instance{
157 ID: id,
158 Path: args.Path,
159 YOLO: cfg.Permissions.SkipRequests,
160 })
161}
162
163func createDotCrushDir(dir string) error {
164 if err := os.MkdirAll(dir, 0o700); err != nil {
165 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
166 }
167
168 gitIgnorePath := filepath.Join(dir, ".gitignore")
169 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
170 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
171 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
172 }
173 }
174
175 return nil
176}
177
178func jsonEncode(w http.ResponseWriter, v any) {
179 w.Header().Set("Content-Type", "application/json")
180 _ = json.NewEncoder(w).Encode(v)
181}
182
183func jsonError(w http.ResponseWriter, status int, message string) {
184 w.Header().Set("Content-Type", "application/json")
185 w.WriteHeader(status)
186 _ = json.NewEncoder(w).Encode(proto.Error{Message: message})
187}