| 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 | h.mux.HandleFunc("POST "+httpRoutePrefix+"capability", h.capability) |
| 34 | return h |
| 35 | } |
| 36 | |
| 37 | func (h *httpHandler) capability(w http.ResponseWriter, r *http.Request) { |
| 38 | var in struct { |
| 39 | Name string `json:"name"` |
| 40 | Args json.RawMessage `json:"args"` |
| 41 | } |
| 42 | if !decodeBody(w, r, &in) { |
| 43 | return |
| 44 | } |
| 45 | exec, ok := h.exec.(CapabilityExecutor) |
| 46 | if !ok { |
| 47 | writeWireError(w, http.StatusNotImplemented, "capability_unsupported", "browser enhancements unavailable") |
| 48 | return |
| 49 | } |
| 50 | // Only published tools can cross this boundary; host RPC names are not input. |
| 51 | for _, candidate := range CapabilityTools(h.exec) { |
| 52 | if candidate.Name() == "browser_"+in.Name { |
| 53 | result, err := exec.BrowserCapability(sessionContext(r), in.Name, in.Args) |
| 54 | writeResult(w, result, err) |
| 55 | return |
| 56 | } |
| 57 | } |
| 58 | writeWireError(w, http.StatusNotImplemented, "capability_unsupported", "unknown browser capability") |
| 59 | } |
| 60 | |
| 61 | func (h *httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 62 | if !h.authorized(r) { |
| 63 | w.Header().Set("WWW-Authenticate", `Bearer realm="reasonix-browser"`) |
| 64 | writeWireError(w, http.StatusUnauthorized, "unauthorized", "invalid browser broker token") |
| 65 | return |
| 66 | } |
| 67 | h.mux.ServeHTTP(w, r) |
| 68 | } |
| 69 | |
| 70 | func (h *httpHandler) authorized(r *http.Request) bool { |
| 71 | prefix, value, ok := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ") |
| 72 | if !ok || !strings.EqualFold(prefix, "Bearer") || h.token == "" { |
| 73 | return false |
| 74 | } |
| 75 | return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(value)), []byte(h.token)) == 1 |
| 76 | } |
| 77 | |
| 78 | func (h *httpHandler) health(w http.ResponseWriter, r *http.Request) { |
| 79 | if a, ok := h.exec.(Availability); ok && !a.Available(sessionContext(r)) { |
| 80 | writeWireError(w, http.StatusServiceUnavailable, "unavailable", "no browser is available for this session") |
| 81 | return |
| 82 | } |
| 83 | w.WriteHeader(http.StatusNoContent) |
| 84 | } |
| 85 | |
| 86 | func sessionContext(r *http.Request) context.Context { |
| 87 | return WithSession(r.Context(), strings.TrimSpace(r.Header.Get(SessionHeader))) |
| 88 | } |
| 89 | |
| 90 | // decode reads a bounded JSON body into in; a false return means the reply |
| 91 | // was already written. |
| 92 | func decodeBody(w http.ResponseWriter, r *http.Request, in any) bool { |
| 93 | r.Body = http.MaxBytesReader(w, r.Body, httpMaxRequestBytes) |
| 94 | if err := json.NewDecoder(r.Body).Decode(in); err != nil { |
| 95 | writeWireError(w, http.StatusBadRequest, "bad_request", "invalid JSON body: "+err.Error()) |
| 96 | return false |
| 97 | } |
| 98 | return true |
| 99 | } |
| 100 | |
| 101 | func writeWireError(w http.ResponseWriter, status int, code, message string) { |
| 102 | w.Header().Set("Content-Type", "application/json") |
| 103 | w.WriteHeader(status) |
| 104 | _ = json.NewEncoder(w).Encode(wireError{Error: code, Message: message}) |
| 105 | } |
| 106 | |
| 107 | // writeResult maps err onto the wire: sentinels become 409 with their code, |
| 108 | // anything else is a 500 the client reports as a plain error. |
| 109 | func writeResult(w http.ResponseWriter, v any, err error) { |
| 110 | if err != nil { |
| 111 | for _, code := range []string{wireStaleReference, wireTakenOver, wireNoGrant, wireUnknownOutcome} { |
| 112 | if errors.Is(err, wireErrorCodes[code]) { |
| 113 | writeWireError(w, http.StatusConflict, code, err.Error()) |
| 114 | return |
| 115 | } |
| 116 | } |
| 117 | writeWireError(w, http.StatusInternalServerError, "failed", err.Error()) |
| 118 | return |
| 119 | } |
| 120 | w.Header().Set("Content-Type", "application/json") |
| 121 | _ = json.NewEncoder(w).Encode(v) |
| 122 | } |
| 123 | |
| 124 | func (h *httpHandler) tabs(w http.ResponseWriter, r *http.Request) { |
| 125 | var in struct{} |
| 126 | if !decodeBody(w, r, &in) { |
| 127 | return |
| 128 | } |
| 129 | tabs, err := h.exec.Tabs(sessionContext(r)) |
| 130 | out := wireTabs{Tabs: make([]wireTab, 0, len(tabs))} |
| 131 | for _, t := range tabs { |
| 132 | out.Tabs = append(out.Tabs, toWireTab(t)) |
| 133 | } |
| 134 | writeResult(w, out, err) |
| 135 | } |
| 136 | |
| 137 | func (h *httpHandler) open(w http.ResponseWriter, r *http.Request) { |
| 138 | var in wireOpenRequest |
| 139 | if !decodeBody(w, r, &in) { |
| 140 | return |
| 141 | } |
| 142 | tab, err := h.exec.Open(sessionContext(r), OpenRequest(in)) |
| 143 | writeResult(w, toWireTab(tab), err) |
| 144 | } |
| 145 | |
| 146 | func (h *httpHandler) navigate(w http.ResponseWriter, r *http.Request) { |
| 147 | var in wireNavigateRequest |
| 148 | if !decodeBody(w, r, &in) { |
| 149 | return |
| 150 | } |
| 151 | tab, err := h.exec.Navigate(sessionContext(r), NavigateRequest(in)) |
| 152 | writeResult(w, toWireTab(tab), err) |
| 153 | } |
| 154 | |
| 155 | func (h *httpHandler) snapshot(w http.ResponseWriter, r *http.Request) { |
| 156 | var in wireSnapshotRequest |
| 157 | if !decodeBody(w, r, &in) { |
| 158 | return |
| 159 | } |
| 160 | snap, err := h.exec.Snapshot(sessionContext(r), SnapshotRequest(in)) |
| 161 | writeResult(w, wireSnapshot(snap), err) |
| 162 | } |
| 163 | |
| 164 | func (h *httpHandler) screenshot(w http.ResponseWriter, r *http.Request) { |
| 165 | var in wireScreenshotRequest |
| 166 | if !decodeBody(w, r, &in) { |
| 167 | return |
| 168 | } |
| 169 | shot, err := h.exec.Screenshot(sessionContext(r), ScreenshotRequest(in)) |
| 170 | writeResult(w, wireScreenshot(shot), err) |
| 171 | } |
| 172 | |
| 173 | func (h *httpHandler) act(w http.ResponseWriter, r *http.Request) { |
| 174 | var in wireActRequest |
| 175 | if !decodeBody(w, r, &in) { |
| 176 | return |
| 177 | } |
| 178 | res, err := h.exec.Act(sessionContext(r), in.request()) |
| 179 | writeResult(w, wireActResult(res), err) |
| 180 | } |
| 181 | |
| 182 | func (h *httpHandler) downloads(w http.ResponseWriter, r *http.Request) { |
| 183 | var in wireDownloadsRequest |
| 184 | if !decodeBody(w, r, &in) { |
| 185 | return |
| 186 | } |
| 187 | downloads, err := h.exec.Downloads(sessionContext(r), in.request()) |
| 188 | out := wireDownloads{Downloads: make([]wireDownload, 0, len(downloads))} |
| 189 | for _, d := range downloads { |
| 190 | out.Downloads = append(out.Downloads, wireDownload(d)) |
| 191 | } |
| 192 | writeResult(w, out, err) |
| 193 | } |
| 194 | |
| 195 | func (h *httpHandler) close(w http.ResponseWriter, r *http.Request) { |
| 196 | var in wireCloseRequest |
| 197 | if !decodeBody(w, r, &in) { |
| 198 | return |
| 199 | } |
| 200 | writeResult(w, struct{}{}, h.exec.Close(sessionContext(r), CloseRequest(in))) |
| 201 | } |
| 202 |