| 1 | package browser |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/subtle" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "net/http" |
| 9 | "strings" |
| 10 | ) |
| 11 | |
| 12 | // httpHandler serves the contract over any Executor: constant-time bearer |
| 13 | // check, bounded JSON bodies, sentinels as 409 bodies, everything else 500. |
| 14 | type httpHandler struct { |
| 15 | exec Executor |
| 16 | token string |
| 17 | mux *http.ServeMux |
| 18 | } |
| 19 | |
| 20 | // NewHTTPHandler exposes exec at /v1/browser/<method> behind a bearer token. |
| 21 | // GET /v1/browser/health answers 204 while exec is available, 503 otherwise. |
| 22 | func NewHTTPHandler(exec Executor, token string) http.Handler { |
| 23 | h := &httpHandler{exec: exec, token: strings.TrimSpace(token), mux: http.NewServeMux()} |
| 24 | h.mux.HandleFunc("GET "+httpHealthRoute, h.health) |
| 25 | h.mux.HandleFunc("POST "+httpRoutePrefix+"tabs", h.tabs) |
| 26 | h.mux.HandleFunc("POST "+httpRoutePrefix+"open", h.open) |
| 27 | h.mux.HandleFunc("POST "+httpRoutePrefix+"navigate", h.navigate) |
| 28 | h.mux.HandleFunc("POST "+httpRoutePrefix+"snapshot", h.snapshot) |
| 29 | h.mux.HandleFunc("POST "+httpRoutePrefix+"screenshot", h.screenshot) |
| 30 | h.mux.HandleFunc("POST "+httpRoutePrefix+"act", h.act) |
| 31 | h.mux.HandleFunc("POST "+httpRoutePrefix+"downloads", h.downloads) |
| 32 | h.mux.HandleFunc("POST "+httpRoutePrefix+"close", h.close) |
| 33 | return h |
| 34 | } |
| 35 | |
| 36 | func (h *httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 37 | if !h.authorized(r) { |
| 38 | w.Header().Set("WWW-Authenticate", `Bearer realm="reasonix-browser"`) |
| 39 | writeWireError(w, http.StatusUnauthorized, "unauthorized", "invalid browser broker token") |
| 40 | return |
| 41 | } |
| 42 | h.mux.ServeHTTP(w, r) |
| 43 | } |
| 44 | |
| 45 | func (h *httpHandler) authorized(r *http.Request) bool { |
| 46 | prefix, value, ok := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ") |
| 47 | if !ok || !strings.EqualFold(prefix, "Bearer") || h.token == "" { |
| 48 | return false |
| 49 | } |
| 50 | return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(value)), []byte(h.token)) == 1 |
| 51 | } |
| 52 | |
| 53 | func (h *httpHandler) health(w http.ResponseWriter, r *http.Request) { |
| 54 | if a, ok := h.exec.(Availability); ok && !a.Available(sessionContext(r)) { |
| 55 | writeWireError(w, http.StatusServiceUnavailable, "unavailable", "no browser is available for this session") |
| 56 | return |
| 57 | } |
| 58 | w.WriteHeader(http.StatusNoContent) |
| 59 | } |
| 60 | |
| 61 | func sessionContext(r *http.Request) context.Context { |
| 62 | return WithSession(r.Context(), strings.TrimSpace(r.Header.Get(SessionHeader))) |
| 63 | } |
| 64 | |
| 65 | // decode reads a bounded JSON body into in; a false return means the reply |
| 66 | // was already written. |
| 67 | func decodeBody(w http.ResponseWriter, r *http.Request, in any) bool { |
| 68 | r.Body = http.MaxBytesReader(w, r.Body, httpMaxRequestBytes) |
| 69 | if err := json.NewDecoder(r.Body).Decode(in); err != nil { |
| 70 | writeWireError(w, http.StatusBadRequest, "bad_request", "invalid JSON body: "+err.Error()) |
| 71 | return false |
| 72 | } |
| 73 | return true |
| 74 | } |
| 75 | |
| 76 | func writeWireError(w http.ResponseWriter, status int, code, message string) { |
| 77 | w.Header().Set("Content-Type", "application/json") |
| 78 | w.WriteHeader(status) |
| 79 | _ = json.NewEncoder(w).Encode(wireError{Error: code, Message: message}) |
| 80 | } |
| 81 | |
| 82 | // writeResult maps err onto the wire: sentinels become 409 with their code, |
| 83 | // anything else is a 500 the client reports as a plain error. |
| 84 | func writeResult(w http.ResponseWriter, v any, err error) { |
| 85 | if err != nil { |
| 86 | for _, code := range []string{wireStaleReference, wireTakenOver, wireNoGrant, wireUnknownOutcome} { |
| 87 | if errors.Is(err, wireErrorCodes[code]) { |
| 88 | writeWireError(w, http.StatusConflict, code, err.Error()) |
| 89 | return |
| 90 | } |
| 91 | } |
| 92 | writeWireError(w, http.StatusInternalServerError, "failed", err.Error()) |
| 93 | return |
| 94 | } |
| 95 | w.Header().Set("Content-Type", "application/json") |
| 96 | _ = json.NewEncoder(w).Encode(v) |
| 97 | } |
| 98 | |
| 99 | func (h *httpHandler) tabs(w http.ResponseWriter, r *http.Request) { |
| 100 | var in struct{} |
| 101 | if !decodeBody(w, r, &in) { |
| 102 | return |
| 103 | } |
| 104 | tabs, err := h.exec.Tabs(sessionContext(r)) |
| 105 | out := wireTabs{Tabs: make([]wireTab, 0, len(tabs))} |
| 106 | for _, t := range tabs { |
| 107 | out.Tabs = append(out.Tabs, toWireTab(t)) |
| 108 | } |
| 109 | writeResult(w, out, err) |
| 110 | } |
| 111 | |
| 112 | func (h *httpHandler) open(w http.ResponseWriter, r *http.Request) { |
| 113 | var in wireOpenRequest |
| 114 | if !decodeBody(w, r, &in) { |
| 115 | return |
| 116 | } |
| 117 | tab, err := h.exec.Open(sessionContext(r), OpenRequest(in)) |
| 118 | writeResult(w, toWireTab(tab), err) |
| 119 | } |
| 120 | |
| 121 | func (h *httpHandler) navigate(w http.ResponseWriter, r *http.Request) { |
| 122 | var in wireNavigateRequest |
| 123 | if !decodeBody(w, r, &in) { |
| 124 | return |
| 125 | } |
| 126 | tab, err := h.exec.Navigate(sessionContext(r), NavigateRequest(in)) |
| 127 | writeResult(w, toWireTab(tab), err) |
| 128 | } |
| 129 | |
| 130 | func (h *httpHandler) snapshot(w http.ResponseWriter, r *http.Request) { |
| 131 | var in wireSnapshotRequest |
| 132 | if !decodeBody(w, r, &in) { |
| 133 | return |
| 134 | } |
| 135 | snap, err := h.exec.Snapshot(sessionContext(r), SnapshotRequest(in)) |
| 136 | writeResult(w, wireSnapshot(snap), err) |
| 137 | } |
| 138 | |
| 139 | func (h *httpHandler) screenshot(w http.ResponseWriter, r *http.Request) { |
| 140 | var in wireScreenshotRequest |
| 141 | if !decodeBody(w, r, &in) { |
| 142 | return |
| 143 | } |
| 144 | shot, err := h.exec.Screenshot(sessionContext(r), ScreenshotRequest(in)) |
| 145 | writeResult(w, wireScreenshot(shot), err) |
| 146 | } |
| 147 | |
| 148 | func (h *httpHandler) act(w http.ResponseWriter, r *http.Request) { |
| 149 | var in wireActRequest |
| 150 | if !decodeBody(w, r, &in) { |
| 151 | return |
| 152 | } |
| 153 | res, err := h.exec.Act(sessionContext(r), in.request()) |
| 154 | writeResult(w, wireActResult(res), err) |
| 155 | } |
| 156 | |
| 157 | func (h *httpHandler) downloads(w http.ResponseWriter, r *http.Request) { |
| 158 | var in wireDownloadsRequest |
| 159 | if !decodeBody(w, r, &in) { |
| 160 | return |
| 161 | } |
| 162 | downloads, err := h.exec.Downloads(sessionContext(r), in.request()) |
| 163 | out := wireDownloads{Downloads: make([]wireDownload, 0, len(downloads))} |
| 164 | for _, d := range downloads { |
| 165 | out.Downloads = append(out.Downloads, wireDownload(d)) |
| 166 | } |
| 167 | writeResult(w, out, err) |
| 168 | } |
| 169 | |
| 170 | func (h *httpHandler) close(w http.ResponseWriter, r *http.Request) { |
| 171 | var in wireCloseRequest |
| 172 | if !decodeBody(w, r, &in) { |
| 173 | return |
| 174 | } |
| 175 | writeResult(w, struct{}{}, h.exec.Close(sessionContext(r), CloseRequest(in))) |
| 176 | } |
| 177 |