|
@@ -0,0 +1,312 @@
|
|
|
|
+/*
|
|
|
|
+ * Copyright (c) 2022, Florent Castelli <florent.castelli@gmail.com>
|
|
|
|
+ * Copyright (c) 2022, Sam Atkins <atkinssj@serenityos.org>
|
|
|
|
+ * Copyright (c) 2022, Tobias Christiansen <tobyase@serenityos.org>
|
|
|
|
+ * Copyright (c) 2022, Linus Groh <linusg@serenityos.org>
|
|
|
|
+ * Copyright (c) 2022, Tim Flynn <trflynn89@serenityos.org>
|
|
|
|
+ *
|
|
|
|
+ * SPDX-License-Identifier: BSD-2-Clause
|
|
|
|
+ */
|
|
|
|
+
|
|
|
|
+#include <AK/ByteBuffer.h>
|
|
|
|
+#include <AK/Debug.h>
|
|
|
|
+#include <AK/Format.h>
|
|
|
|
+#include <AK/JsonObject.h>
|
|
|
|
+#include <AK/JsonParser.h>
|
|
|
|
+#include <AK/JsonValue.h>
|
|
|
|
+#include <AK/Span.h>
|
|
|
|
+#include <AK/StringBuilder.h>
|
|
|
|
+#include <AK/StringView.h>
|
|
|
|
+#include <LibCore/DateTime.h>
|
|
|
|
+#include <LibHTTP/HttpResponse.h>
|
|
|
|
+#include <LibWeb/WebDriver/Client.h>
|
|
|
|
+
|
|
|
|
+namespace Web::WebDriver {
|
|
|
|
+
|
|
|
|
+using RouteHandler = Response (*)(Client&, Parameters, JsonValue);
|
|
|
|
+
|
|
|
|
+struct Route {
|
|
|
|
+ HTTP::HttpRequest::Method method {};
|
|
|
|
+ StringView path;
|
|
|
|
+ RouteHandler handler { nullptr };
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+struct MatchedRoute {
|
|
|
|
+ RouteHandler handler;
|
|
|
|
+ Vector<StringView> parameters;
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+// clang-format off
|
|
|
|
+// This would be formatted rather badly.
|
|
|
|
+#define ROUTE(method, path, handler) \
|
|
|
|
+ Route { \
|
|
|
|
+ HTTP::HttpRequest::method, \
|
|
|
|
+ path, \
|
|
|
|
+ [](auto& client, auto parameters, auto payload) { \
|
|
|
|
+ return client.handler(parameters, move(payload)); \
|
|
|
|
+ } \
|
|
|
|
+ }
|
|
|
|
+// clang-format on
|
|
|
|
+
|
|
|
|
+// https://w3c.github.io/webdriver/#dfn-endpoints
|
|
|
|
+static constexpr auto s_webdriver_endpoints = Array {
|
|
|
|
+ ROUTE(POST, "/session"sv, new_session),
|
|
|
|
+ ROUTE(DELETE, "/session/:session_id"sv, delete_session),
|
|
|
|
+ ROUTE(GET, "/status"sv, get_status),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/timeouts"sv, get_timeouts),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/timeouts"sv, set_timeouts),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/url"sv, navigate_to),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/url"sv, get_current_url),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/back"sv, back),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/forward"sv, forward),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/refresh"sv, refresh),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/title"sv, get_title),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/window"sv, get_window_handle),
|
|
|
|
+ ROUTE(DELETE, "/session/:session_id/window"sv, close_window),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/window/handles"sv, get_window_handles),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/window/rect"sv, get_window_rect),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/window/rect"sv, set_window_rect),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/window/maximize"sv, maximize_window),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/window/minimize"sv, minimize_window),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/element"sv, find_element),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/elements"sv, find_elements),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/element/:element_id/element"sv, find_element_from_element),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/element/:element_id/elements"sv, find_elements_from_element),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/element/:element_id/selected"sv, is_element_selected),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/element/:element_id/attribute/:name"sv, get_element_attribute),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/element/:element_id/property/:name"sv, get_element_property),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/element/:element_id/css/:name"sv, get_element_css_value),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/element/:element_id/text"sv, get_element_text),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/element/:element_id/name"sv, get_element_tag_name),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/element/:element_id/rect"sv, get_element_rect),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/element/:element_id/enabled"sv, is_element_enabled),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/source"sv, get_source),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/execute/sync"sv, execute_script),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/execute/async"sv, execute_async_script),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/cookie"sv, get_all_cookies),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/cookie/:name"sv, get_named_cookie),
|
|
|
|
+ ROUTE(POST, "/session/:session_id/cookie"sv, add_cookie),
|
|
|
|
+ ROUTE(DELETE, "/session/:session_id/cookie/:name"sv, delete_cookie),
|
|
|
|
+ ROUTE(DELETE, "/session/:session_id/cookie"sv, delete_all_cookies),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/screenshot"sv, take_screenshot),
|
|
|
|
+ ROUTE(GET, "/session/:session_id/element/:element_id/screenshot"sv, take_element_screenshot),
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+// https://w3c.github.io/webdriver/#dfn-match-a-request
|
|
|
|
+static ErrorOr<MatchedRoute, Error> match_route(HTTP::HttpRequest const& request)
|
|
|
|
+{
|
|
|
|
+ dbgln_if(WEBDRIVER_DEBUG, "match_route({}, {})", HTTP::to_string(request.method()), request.resource());
|
|
|
|
+
|
|
|
|
+ auto request_path = request.resource().view();
|
|
|
|
+ Vector<StringView> parameters;
|
|
|
|
+
|
|
|
|
+ auto next_segment = [](auto& path) -> Optional<StringView> {
|
|
|
|
+ if (auto index = path.find('/'); index.has_value() && (*index + 1) < path.length()) {
|
|
|
|
+ path = path.substring_view(*index + 1);
|
|
|
|
+
|
|
|
|
+ if (index = path.find('/'); index.has_value())
|
|
|
|
+ return path.substring_view(0, *index);
|
|
|
|
+ return path;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ path = {};
|
|
|
|
+ return {};
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ for (auto const& route : s_webdriver_endpoints) {
|
|
|
|
+ dbgln_if(WEBDRIVER_DEBUG, "- Checking {} {}", HTTP::to_string(route.method), route.path);
|
|
|
|
+ if (route.method != request.method())
|
|
|
|
+ continue;
|
|
|
|
+
|
|
|
|
+ auto route_path = route.path;
|
|
|
|
+ Optional<bool> match;
|
|
|
|
+
|
|
|
|
+ auto on_failed_match = [&]() {
|
|
|
|
+ request_path = request.resource();
|
|
|
|
+ parameters.clear();
|
|
|
|
+ match = false;
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ while (!match.has_value()) {
|
|
|
|
+ auto request_segment = next_segment(request_path);
|
|
|
|
+ auto route_segment = next_segment(route_path);
|
|
|
|
+
|
|
|
|
+ if (!request_segment.has_value() && !route_segment.has_value())
|
|
|
|
+ match = true;
|
|
|
|
+ else if (request_segment.has_value() != route_segment.has_value())
|
|
|
|
+ on_failed_match();
|
|
|
|
+ else if (route_segment->starts_with(':'))
|
|
|
|
+ parameters.append(*request_segment);
|
|
|
|
+ else if (request_segment != route_segment)
|
|
|
|
+ on_failed_match();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (*match) {
|
|
|
|
+ dbgln_if(WEBDRIVER_DEBUG, "- Found match with parameters={}", parameters);
|
|
|
|
+ return MatchedRoute { route.handler, parameters };
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return Error::from_code(ErrorCode::UnknownCommand, "The command was not recognized.");
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+Client::Client(NonnullOwnPtr<Core::Stream::BufferedTCPSocket> socket, Core::Object* parent)
|
|
|
|
+ : Core::Object(parent)
|
|
|
|
+ , m_socket(move(socket))
|
|
|
|
+{
|
|
|
|
+ m_socket->on_ready_to_read = [this] {
|
|
|
|
+ if (auto result = on_ready_to_read(); result.is_error()) {
|
|
|
|
+ result.error().visit(
|
|
|
|
+ [](AK::Error const& error) {
|
|
|
|
+ warnln("Internal error: {}", error);
|
|
|
|
+ },
|
|
|
|
+ [this](WebDriver::Error const& error) {
|
|
|
|
+ if (send_error_response(error).is_error())
|
|
|
|
+ warnln("Could not send error response");
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ die();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ m_request = {};
|
|
|
|
+ };
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+Client::~Client()
|
|
|
|
+{
|
|
|
|
+ m_socket->close();
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+void Client::die()
|
|
|
|
+{
|
|
|
|
+ deferred_invoke([this] { remove_from_parent(); });
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+ErrorOr<void, Client::WrappedError> Client::on_ready_to_read()
|
|
|
|
+{
|
|
|
|
+ // FIXME: All this should be moved to LibHTTP and be made spec compliant.
|
|
|
|
+ auto buffer = TRY(ByteBuffer::create_uninitialized(m_socket->buffer_size()));
|
|
|
|
+ StringBuilder builder;
|
|
|
|
+
|
|
|
|
+ for (;;) {
|
|
|
|
+ if (!TRY(m_socket->can_read_without_blocking()))
|
|
|
|
+ break;
|
|
|
|
+
|
|
|
|
+ auto data = TRY(m_socket->read(buffer));
|
|
|
|
+ TRY(builder.try_append(StringView { data }));
|
|
|
|
+
|
|
|
|
+ if (m_socket->is_eof())
|
|
|
|
+ break;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ m_request = HTTP::HttpRequest::from_raw_request(builder.to_byte_buffer());
|
|
|
|
+ if (!m_request.has_value())
|
|
|
|
+ return {};
|
|
|
|
+
|
|
|
|
+ auto body = TRY(read_body_as_json());
|
|
|
|
+ TRY(handle_request(move(body)));
|
|
|
|
+
|
|
|
|
+ return {};
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+ErrorOr<JsonValue, Client::WrappedError> Client::read_body_as_json()
|
|
|
|
+{
|
|
|
|
+ // FIXME: If we received a multipart body here, this would fail badly.
|
|
|
|
+ // FIXME: Check the Content-Type is actually application/json.
|
|
|
|
+ size_t content_length = 0;
|
|
|
|
+
|
|
|
|
+ for (auto const& header : m_request->headers()) {
|
|
|
|
+ if (header.name.equals_ignoring_case("Content-Length"sv)) {
|
|
|
|
+ content_length = header.value.to_uint<size_t>(TrimWhitespace::Yes).value_or(0);
|
|
|
|
+ break;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (content_length == 0)
|
|
|
|
+ return JsonValue {};
|
|
|
|
+
|
|
|
|
+ JsonParser json_parser(m_request->body());
|
|
|
|
+ return TRY(json_parser.parse());
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+ErrorOr<void, Client::WrappedError> Client::handle_request(JsonValue body)
|
|
|
|
+{
|
|
|
|
+ if constexpr (WEBDRIVER_DEBUG) {
|
|
|
|
+ dbgln("Got HTTP request: {} {}", m_request->method_name(), m_request->resource());
|
|
|
|
+ if (!body.is_null())
|
|
|
|
+ dbgln("Body: {}", body.to_string());
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ auto const& [handler, parameters] = TRY(match_route(*m_request));
|
|
|
|
+ auto result = TRY((*handler)(*this, parameters, move(body)));
|
|
|
|
+ return send_success_response(move(result));
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+ErrorOr<void, Client::WrappedError> Client::send_success_response(JsonValue result)
|
|
|
|
+{
|
|
|
|
+ auto content = result.serialized<StringBuilder>();
|
|
|
|
+
|
|
|
|
+ StringBuilder builder;
|
|
|
|
+ builder.append("HTTP/1.0 200 OK\r\n"sv);
|
|
|
|
+ builder.append("Server: WebDriver (SerenityOS)\r\n"sv);
|
|
|
|
+ builder.append("X-Frame-Options: SAMEORIGIN\r\n"sv);
|
|
|
|
+ builder.append("X-Content-Type-Options: nosniff\r\n"sv);
|
|
|
|
+ builder.append("Pragma: no-cache\r\n"sv);
|
|
|
|
+ builder.append("Content-Type: application/json; charset=utf-8\r\n"sv);
|
|
|
|
+ builder.appendff("Content-Length: {}\r\n", content.length());
|
|
|
|
+ builder.append("\r\n"sv);
|
|
|
|
+
|
|
|
|
+ auto builder_contents = builder.to_byte_buffer();
|
|
|
|
+ TRY(m_socket->write(builder_contents));
|
|
|
|
+
|
|
|
|
+ while (!content.is_empty()) {
|
|
|
|
+ auto bytes_sent = TRY(m_socket->write(content.bytes()));
|
|
|
|
+ content = content.substring_view(bytes_sent);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ bool keep_alive = false;
|
|
|
|
+ if (auto it = m_request->headers().find_if([](auto& header) { return header.name.equals_ignoring_case("Connection"sv); }); !it.is_end())
|
|
|
|
+ keep_alive = it->value.trim_whitespace().equals_ignoring_case("keep-alive"sv);
|
|
|
|
+
|
|
|
|
+ if (!keep_alive)
|
|
|
|
+ die();
|
|
|
|
+
|
|
|
|
+ log_response(200);
|
|
|
|
+ return {};
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+ErrorOr<void, Client::WrappedError> Client::send_error_response(Error const& error)
|
|
|
|
+{
|
|
|
|
+ // FIXME: Implement to spec.
|
|
|
|
+ dbgln_if(WEBDRIVER_DEBUG, "Sending error response: {} {}: {}", error.http_status, error.error, error.message);
|
|
|
|
+ auto reason = HTTP::HttpResponse::reason_phrase_for_code(error.http_status);
|
|
|
|
+
|
|
|
|
+ JsonObject result;
|
|
|
|
+ result.set("error", error.error);
|
|
|
|
+ result.set("message", error.message);
|
|
|
|
+ result.set("stacktrace", "");
|
|
|
|
+ if (error.data.has_value())
|
|
|
|
+ result.set("data", *error.data);
|
|
|
|
+
|
|
|
|
+ StringBuilder content_builder;
|
|
|
|
+ result.serialize(content_builder);
|
|
|
|
+
|
|
|
|
+ StringBuilder header_builder;
|
|
|
|
+ header_builder.appendff("HTTP/1.0 {} {}\r\n", error.http_status, reason);
|
|
|
|
+ header_builder.append("Content-Type: application/json; charset=UTF-8\r\n"sv);
|
|
|
|
+ header_builder.appendff("Content-Length: {}\r\n", content_builder.length());
|
|
|
|
+ header_builder.append("\r\n"sv);
|
|
|
|
+
|
|
|
|
+ TRY(m_socket->write(header_builder.to_byte_buffer()));
|
|
|
|
+ TRY(m_socket->write(content_builder.to_byte_buffer()));
|
|
|
|
+
|
|
|
|
+ log_response(error.http_status);
|
|
|
|
+ return {};
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+void Client::log_response(unsigned code)
|
|
|
|
+{
|
|
|
|
+ outln("{} :: {:03d} :: {} {}", Core::DateTime::now().to_string(), code, m_request->method_name(), m_request->resource());
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+}
|