github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/hud/server/server.go (about) 1 package server 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log" 8 "net/http" 9 _ "net/http/pprof" 10 11 "google.golang.org/protobuf/types/known/timestamppb" 12 13 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 14 15 "github.com/golang/protobuf/jsonpb" 16 "github.com/google/uuid" 17 "github.com/gorilla/mux" 18 _ "github.com/gorilla/websocket" 19 "github.com/grpc-ecosystem/grpc-gateway/runtime" 20 jsoniter "github.com/json-iterator/go" 21 ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" 22 23 tiltanalytics "github.com/tilt-dev/tilt/internal/analytics" 24 "github.com/tilt-dev/tilt/internal/hud/webview" 25 "github.com/tilt-dev/tilt/internal/store" 26 "github.com/tilt-dev/tilt/internal/store/tiltfiles" 27 "github.com/tilt-dev/tilt/pkg/assets" 28 "github.com/tilt-dev/tilt/pkg/model" 29 proto_webview "github.com/tilt-dev/tilt/pkg/webview" 30 "github.com/tilt-dev/wmclient/pkg/analytics" 31 ) 32 33 const TiltTokenCookieName = "Tilt-Token" 34 35 // CSRF token to protect the websocket. See: 36 // https://dev.solita.fi/2018/11/07/securing-websocket-endpoints.html 37 // https://christian-schneider.net/CrossSiteWebSocketHijacking.html 38 var websocketCSRFToken = uuid.New() 39 40 type analyticsPayload struct { 41 Verb string `json:"verb"` 42 Name string `json:"name"` 43 Tags map[string]string `json:"tags"` 44 } 45 46 type analyticsOptPayload struct { 47 Opt string `json:"opt"` 48 } 49 50 type triggerPayload struct { 51 ManifestNames []string `json:"manifest_names"` 52 BuildReason model.BuildReason `json:"build_reason"` 53 } 54 55 type overrideTriggerModePayload struct { 56 ManifestNames []string `json:"manifest_names"` 57 TriggerMode int `json:"trigger_mode"` 58 } 59 60 type HeadsUpServer struct { 61 ctx context.Context 62 store *store.Store 63 router *mux.Router 64 a *tiltanalytics.TiltAnalytics 65 wsList *WebsocketList 66 ctrlClient ctrlclient.Client 67 } 68 69 func ProvideHeadsUpServer( 70 ctx context.Context, 71 store *store.Store, 72 assetServer assets.Server, 73 analytics *tiltanalytics.TiltAnalytics, 74 wsList *WebsocketList, 75 ctrlClient ctrlclient.Client) (*HeadsUpServer, error) { 76 r := mux.NewRouter().UseEncodedPath() 77 s := &HeadsUpServer{ 78 ctx: ctx, 79 store: store, 80 router: r, 81 a: analytics, 82 wsList: wsList, 83 ctrlClient: ctrlClient, 84 } 85 86 r.HandleFunc("/api/view", s.ViewJSON) 87 r.HandleFunc("/api/dump/engine", s.DumpEngineJSON) 88 r.HandleFunc("/api/analytics", s.HandleAnalytics) 89 r.HandleFunc("/api/analytics_opt", s.HandleAnalyticsOpt) 90 r.HandleFunc("/api/trigger", s.HandleTrigger) 91 r.HandleFunc("/api/override/trigger_mode", s.HandleOverrideTriggerMode) 92 // this endpoint is only used for testing snapshots in development 93 r.HandleFunc("/api/snapshot/{snapshot_id}", s.SnapshotJSON) 94 r.HandleFunc("/api/websocket_token", s.WebsocketToken) 95 r.HandleFunc("/ws/view", s.ViewWebsocket) 96 r.HandleFunc("/api/set_tiltfile_args", s.HandleSetTiltfileArgs).Methods("POST") 97 98 r.PathPrefix("/").Handler(s.cookieWrapper(assetServer)) 99 100 return s, nil 101 } 102 103 type funcHandler struct { 104 f func(w http.ResponseWriter, r *http.Request) 105 } 106 107 func (fh funcHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 108 fh.f(w, r) 109 } 110 111 func (s *HeadsUpServer) cookieWrapper(handler http.Handler) http.Handler { 112 return funcHandler{f: func(w http.ResponseWriter, r *http.Request) { 113 state := s.store.RLockState() 114 http.SetCookie(w, &http.Cookie{Name: TiltTokenCookieName, Value: string(state.Token), Path: "/"}) 115 s.store.RUnlockState() 116 handler.ServeHTTP(w, r) 117 }} 118 } 119 120 func (s *HeadsUpServer) Router() http.Handler { 121 return s.router 122 } 123 124 func (s *HeadsUpServer) ViewJSON(w http.ResponseWriter, req *http.Request) { 125 view, err := webview.CompleteView(req.Context(), s.ctrlClient, s.store) 126 if err != nil { 127 http.Error(w, fmt.Sprintf("Error converting view to proto: %v", err), http.StatusInternalServerError) 128 return 129 } 130 131 jsEncoder := &runtime.JSONPb{} 132 133 w.Header().Set("Content-Type", "application/json") 134 err = jsEncoder.NewEncoder(w).Encode(view) 135 if err != nil { 136 http.Error(w, fmt.Sprintf("Error rendering view payload: %v", err), http.StatusInternalServerError) 137 } 138 } 139 140 // Dump the JSON engine over http. Only intended for 'tilt dump engine'. 141 func (s *HeadsUpServer) DumpEngineJSON(w http.ResponseWriter, req *http.Request) { 142 state := s.store.RLockState() 143 defer s.store.RUnlockState() 144 145 encoder := store.CreateEngineStateEncoder(w) 146 err := encoder.Encode(state) 147 if err != nil { 148 log.Printf("Error encoding: %v", err) 149 } 150 } 151 152 func (s *HeadsUpServer) SnapshotJSON(w http.ResponseWriter, req *http.Request) { 153 view, err := webview.CompleteView(req.Context(), s.ctrlClient, s.store) 154 if err != nil { 155 http.Error(w, fmt.Sprintf("Error converting view to proto: %v", err), http.StatusInternalServerError) 156 return 157 } 158 159 snapshot := &proto_webview.Snapshot{ 160 View: view, 161 CreatedAt: timestamppb.Now(), 162 } 163 164 w.Header().Set("Content-Type", "application/json") 165 var m jsonpb.Marshaler 166 err = m.Marshal(w, snapshot) 167 if err != nil { 168 http.Error(w, fmt.Sprintf("Error rendering view payload: %v", err), http.StatusInternalServerError) 169 } 170 } 171 172 func (s *HeadsUpServer) HandleAnalyticsOpt(w http.ResponseWriter, req *http.Request) { 173 if req.Method != http.MethodPost { 174 http.Error(w, "must be POST request", http.StatusBadRequest) 175 return 176 } 177 178 var payload analyticsOptPayload 179 180 decoder := json.NewDecoder(req.Body) 181 err := decoder.Decode(&payload) 182 if err != nil { 183 http.Error(w, fmt.Sprintf("error parsing JSON payload: %v", err), http.StatusBadRequest) 184 return 185 } 186 187 opt, err := analytics.ParseOpt(payload.Opt) 188 if err != nil { 189 http.Error(w, fmt.Sprintf("error parsing opt '%s': %v", payload.Opt, err), http.StatusBadRequest) 190 } 191 192 // only logging on opt-in, because, well, opting out means the user just told us not to report data on them! 193 if opt == analytics.OptIn { 194 s.a.Incr("analytics.opt.in", nil) 195 } 196 197 s.store.Dispatch(store.AnalyticsUserOptAction{Opt: opt}) 198 } 199 200 func (s *HeadsUpServer) HandleAnalytics(w http.ResponseWriter, req *http.Request) { 201 if req.Method != http.MethodPost { 202 http.Error(w, "must be POST request", http.StatusBadRequest) 203 return 204 } 205 206 var payloads []analyticsPayload 207 208 decoder := json.NewDecoder(req.Body) 209 err := decoder.Decode(&payloads) 210 if err != nil { 211 http.Error(w, fmt.Sprintf("error parsing JSON payload: %v", err), http.StatusBadRequest) 212 return 213 } 214 215 for _, p := range payloads { 216 if p.Verb != "incr" { 217 http.Error(w, "error parsing payloads: only incr verbs are supported", http.StatusBadRequest) 218 return 219 } 220 221 s.a.Incr(p.Name, p.Tags) 222 } 223 } 224 225 func (s *HeadsUpServer) HandleSetTiltfileArgs(w http.ResponseWriter, req *http.Request) { 226 var args []string 227 err := jsoniter.NewDecoder(req.Body).Decode(&args) 228 if err != nil { 229 http.Error(w, fmt.Sprintf("error parsing JSON payload: %v", err), http.StatusBadRequest) 230 return 231 } 232 233 ctx := req.Context() 234 err = tiltfiles.SetTiltfileArgs(ctx, s.ctrlClient, args) 235 if err != nil { 236 http.Error(w, fmt.Sprintf("error updating apiserver: %v", err), http.StatusInternalServerError) 237 return 238 } 239 } 240 241 // Responds with: 242 // * 200/empty body on success 243 // * 200/error message in body on well-formed, unservicable requests (e.g. resource is disabled or doesn't exist) 244 // * 400/error message in body on badly formed requests (e.g., invalid json) 245 func (s *HeadsUpServer) HandleTrigger(w http.ResponseWriter, req *http.Request) { 246 if req.Method != http.MethodPost { 247 http.Error(w, "must be POST request", http.StatusBadRequest) 248 return 249 } 250 251 var payload triggerPayload 252 253 decoder := json.NewDecoder(req.Body) 254 err := decoder.Decode(&payload) 255 if err != nil { 256 http.Error(w, fmt.Sprintf("error parsing JSON payload: %v", err), http.StatusBadRequest) 257 return 258 } 259 260 if len(payload.ManifestNames) != 1 { 261 http.Error(w, fmt.Sprintf("/api/trigger currently supports exactly one manifest name, got %d", len(payload.ManifestNames)), http.StatusBadRequest) 262 return 263 } 264 265 mn := model.ManifestName(payload.ManifestNames[0]) 266 267 state := s.store.RLockState() 268 defer s.store.RUnlockState() 269 ms, ok := state.ManifestState(mn) 270 if !ok { 271 http.Error(w, fmt.Sprintf("resource %q does not exist", mn), http.StatusNotFound) 272 } else if ms != nil && ms.DisableState == v1alpha1.DisableStateDisabled { 273 _, _ = fmt.Fprintf(w, "resource %q is currently disabled", mn) 274 } else { 275 s.store.Dispatch(store.AppendToTriggerQueueAction{Name: mn, Reason: payload.BuildReason}) 276 } 277 } 278 279 func (s *HeadsUpServer) HandleOverrideTriggerMode(w http.ResponseWriter, req *http.Request) { 280 if req.Method != http.MethodPost { 281 http.Error(w, "must be POST request", http.StatusBadRequest) 282 return 283 } 284 285 var payload overrideTriggerModePayload 286 287 decoder := json.NewDecoder(req.Body) 288 decoder.DisallowUnknownFields() 289 err := decoder.Decode(&payload) 290 if err != nil { 291 http.Error(w, fmt.Sprintf("error parsing JSON payload: %v", err), http.StatusBadRequest) 292 return 293 } 294 295 err = checkManifestsExist(s.store, payload.ManifestNames) 296 if err != nil { 297 http.Error(w, err.Error(), http.StatusBadRequest) 298 return 299 } 300 301 if !model.ValidTriggerMode(model.TriggerMode(payload.TriggerMode)) { 302 http.Error(w, fmt.Sprintf("invalid trigger mode: %d", payload.TriggerMode), http.StatusBadRequest) 303 return 304 } 305 306 s.store.Dispatch(OverrideTriggerModeAction{ 307 ManifestNames: model.ManifestNames(payload.ManifestNames), 308 TriggerMode: model.TriggerMode(payload.TriggerMode), 309 }) 310 } 311 312 func (s *HeadsUpServer) WebsocketToken(w http.ResponseWriter, req *http.Request) { 313 w.Header().Set("Content-Type", "text/plain") 314 _, _ = w.Write([]byte(websocketCSRFToken.String())) 315 } 316 317 func checkManifestsExist(st store.RStore, mNames []string) error { 318 state := st.RLockState() 319 defer st.RUnlockState() 320 for _, mName := range mNames { 321 if _, ok := state.ManifestState(model.ManifestName(mName)); !ok { 322 return fmt.Errorf("no manifest found with name '%s'", mName) 323 } 324 } 325 return nil 326 }