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  }