diff --git a/browser/ai_chat/BUILD.gn b/browser/ai_chat/BUILD.gn index 53838786b32b..9d912ed4a70b 100644 --- a/browser/ai_chat/BUILD.gn +++ b/browser/ai_chat/BUILD.gn @@ -10,12 +10,12 @@ assert(enable_ai_chat) static_library("ai_chat") { sources = [ - "//brave/browser/ai_chat/ai_chat_service_factory.cc", - "//brave/browser/ai_chat/ai_chat_service_factory.h", - "//brave/browser/ai_chat/ai_chat_settings_helper.cc", - "//brave/browser/ai_chat/ai_chat_settings_helper.h", - "//brave/browser/ai_chat/ai_chat_utils.cc", - "//brave/browser/ai_chat/ai_chat_utils.h", + "ai_chat_service_factory.cc", + "ai_chat_service_factory.h", + "ai_chat_settings_helper.cc", + "ai_chat_settings_helper.h", + "ai_chat_utils.cc", + "ai_chat_utils.h", ] deps = [ @@ -73,6 +73,7 @@ source_set("browser_tests") { sources = [ "//chrome/browser/renderer_context_menu/render_view_context_menu_browsertest_util.cc", "//chrome/browser/renderer_context_menu/render_view_context_menu_browsertest_util.h", + "ai_chat_brave_search_throttle_browsertest.cc", "ai_chat_browsertests.cc", "ai_chat_metrics_browsertest.cc", "ai_chat_policy_browsertest.cc", diff --git a/browser/ai_chat/ai_chat_brave_search_throttle_browsertest.cc b/browser/ai_chat/ai_chat_brave_search_throttle_browsertest.cc new file mode 100644 index 000000000000..44f4898eafdf --- /dev/null +++ b/browser/ai_chat/ai_chat_brave_search_throttle_browsertest.cc @@ -0,0 +1,233 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h" + +#include +#include + +#include "base/files/file_path.h" +#include "base/location.h" +#include "base/path_service.h" +#include "base/strings/string_util.h" +#include "brave/browser/ui/brave_browser.h" +#include "brave/browser/ui/sidebar/sidebar_controller.h" +#include "brave/browser/ui/sidebar/sidebar_model.h" +#include "brave/components/constants/brave_paths.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/test/base/in_process_browser_test.h" +#include "components/permissions/permission_request_manager.h" +#include "components/permissions/test/mock_permission_prompt_factory.h" +#include "content/public/test/browser_test.h" +#include "content/public/test/browser_test_utils.h" +#include "content/public/test/content_mock_cert_verifier.h" +#include "content/public/test/test_navigation_observer.h" +#include "content/public/test/test_utils.h" +#include "net/base/net_errors.h" +#include "net/dns/mock_host_resolver.h" +#include "net/test/embedded_test_server/embedded_test_server.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +constexpr char kBraveSearchHost[] = "search.brave.com"; +constexpr char kLeoPath[] = "/leo"; +constexpr char kOpenLeoButtonValidPath[] = "/open_leo_button_valid.html"; +constexpr char kOpenLeoButtonInvalidPath[] = "/open_leo_button_invalid.html"; + +} // namespace + +class AIChatBraveSearchThrottleBrowserTest : public InProcessBrowserTest { + public: + AIChatBraveSearchThrottleBrowserTest() + : https_server_(net::EmbeddedTestServer::TYPE_HTTPS) {} + + void SetUpOnMainThread() override { + InProcessBrowserTest::SetUpOnMainThread(); + + mock_cert_verifier_.mock_cert_verifier()->set_default_result(net::OK); + host_resolver()->AddRule("*", "127.0.0.1"); + content::SetupCrossSiteRedirector(&https_server_); + + base::FilePath test_data_dir = + base::PathService::CheckedGet(brave::DIR_TEST_DATA); + test_data_dir = test_data_dir.AppendASCII("leo"); + https_server_.ServeFilesFromDirectory(test_data_dir); + ASSERT_TRUE(https_server_.Start()); + + permissions::PermissionRequestManager* manager = + permissions::PermissionRequestManager::FromWebContents( + GetActiveWebContents()); + prompt_factory_ = + std::make_unique(manager); + } + + void SetUpCommandLine(base::CommandLine* command_line) override { + InProcessBrowserTest::SetUpCommandLine(command_line); + mock_cert_verifier_.SetUpCommandLine(command_line); + } + + void SetUpInProcessBrowserTestFixture() override { + InProcessBrowserTest::SetUpInProcessBrowserTestFixture(); + mock_cert_verifier_.SetUpInProcessBrowserTestFixture(); + } + + void TearDownInProcessBrowserTestFixture() override { + mock_cert_verifier_.TearDownInProcessBrowserTestFixture(); + InProcessBrowserTest::TearDownInProcessBrowserTestFixture(); + } + + content::WebContents* GetActiveWebContents() { + return browser()->tab_strip_model()->GetActiveWebContents(); + } + + void ClickOpenLeoButton() { + // Modify the href to have test server port. + std::string script = R"( + var link = document.getElementById('continue-with-leo') + var url = new URL(link.href) + url.port = '$1' + link.href = url.href + link.click() + )"; + std::string port = base::NumberToString(https_server_.port()); + + ASSERT_TRUE(content::ExecJs( + GetActiveWebContents()->GetPrimaryMainFrame(), + base::ReplaceStringPlaceholders(script, {port}, nullptr))); + } + + bool IsLeoOpened() { + sidebar::SidebarController* controller = + static_cast(browser())->sidebar_controller(); + auto index = controller->model()->GetIndexOf( + sidebar::SidebarItem::BuiltInItemType::kChatUI); + return index.has_value() && controller->IsActiveIndex(index); + } + + void CloseLeoPanel(const base::Location& location) { + SCOPED_TRACE(testing::Message() << location.ToString()); + sidebar::SidebarController* controller = + static_cast(browser())->sidebar_controller(); + controller->DeactivateCurrentPanel(); + ASSERT_FALSE(IsLeoOpened()); + } + + void NavigateToTestPage(const base::Location& location, + const std::string& host, + const std::string& path, + int expected_prompt_count) { + SCOPED_TRACE(testing::Message() << location.ToString()); + ASSERT_TRUE(content::NavigateToURL(GetActiveWebContents(), + https_server_.GetURL(host, path))); + EXPECT_FALSE(IsLeoOpened()); + EXPECT_EQ(expected_prompt_count, prompt_factory_->show_count()); + } + + void ClickOpenLeoAndCheckLeoOpenedAndNavigationCancelled( + const base::Location& location, + int expected_prompt_count, + bool expected_leo_opened, + const std::string& expected_last_committed_path = + kOpenLeoButtonValidPath) { + SCOPED_TRACE(testing::Message() << location.ToString()); + content::TestNavigationObserver observer( + GetActiveWebContents(), net::ERR_ABORTED, + content::MessageLoopRunner::QuitMode::IMMEDIATE, + false /* ignore_uncommitted_navigations */); + ClickOpenLeoButton(); + observer.Wait(); + + EXPECT_EQ(IsLeoOpened(), expected_leo_opened); + EXPECT_EQ(expected_prompt_count, prompt_factory_->show_count()); + EXPECT_EQ(observer.last_navigation_url().path_piece(), kLeoPath); + EXPECT_EQ(GetActiveWebContents()->GetLastCommittedURL().path_piece(), + expected_last_committed_path); + } + + protected: + net::test_server::EmbeddedTestServer https_server_; + std::unique_ptr prompt_factory_; + + private: + content::ContentMockCertVerifier mock_cert_verifier_; +}; + +IN_PROC_BROWSER_TEST_F(AIChatBraveSearchThrottleBrowserTest, + OpenLeo_AskAndAccept) { + int cur_prompt_count = 0; + prompt_factory_->set_response_type( + permissions::PermissionRequestManager::ACCEPT_ALL); + NavigateToTestPage(FROM_HERE, kBraveSearchHost, kOpenLeoButtonValidPath, + cur_prompt_count); + ClickOpenLeoAndCheckLeoOpenedAndNavigationCancelled(FROM_HERE, + ++cur_prompt_count, true); + + CloseLeoPanel(FROM_HERE); + ClickOpenLeoAndCheckLeoOpenedAndNavigationCancelled(FROM_HERE, + cur_prompt_count, true); +} + +IN_PROC_BROWSER_TEST_F(AIChatBraveSearchThrottleBrowserTest, + OpenLeo_AskAndDeny) { + int cur_prompt_count = 0; + prompt_factory_->set_response_type( + permissions::PermissionRequestManager::DENY_ALL); + NavigateToTestPage(FROM_HERE, kBraveSearchHost, kOpenLeoButtonValidPath, + cur_prompt_count); + ClickOpenLeoAndCheckLeoOpenedAndNavigationCancelled( + FROM_HERE, ++cur_prompt_count, false); + + // Clicking a button again to test no new permission prompt should be shown + // when the permission setting is denied. + ClickOpenLeoAndCheckLeoOpenedAndNavigationCancelled(FROM_HERE, + cur_prompt_count, false); +} + +IN_PROC_BROWSER_TEST_F(AIChatBraveSearchThrottleBrowserTest, + OpenLeo_AskAndDismiss) { + int cur_prompt_count = 0; + prompt_factory_->set_response_type( + permissions::PermissionRequestManager::DISMISS); + NavigateToTestPage(FROM_HERE, kBraveSearchHost, kOpenLeoButtonValidPath, + cur_prompt_count); + ClickOpenLeoAndCheckLeoOpenedAndNavigationCancelled( + FROM_HERE, ++cur_prompt_count, false); + + // Click a button again after dismissing the permission, permission prompt + // should be shown again. + prompt_factory_->set_response_type( + permissions::PermissionRequestManager::ACCEPT_ALL); + NavigateToTestPage(FROM_HERE, kBraveSearchHost, kOpenLeoButtonValidPath, + cur_prompt_count); + ClickOpenLeoAndCheckLeoOpenedAndNavigationCancelled(FROM_HERE, + ++cur_prompt_count, true); +} + +IN_PROC_BROWSER_TEST_F(AIChatBraveSearchThrottleBrowserTest, + OpenLeo_MismatchedNonce) { + int cur_prompt_count = 0; + NavigateToTestPage(FROM_HERE, kBraveSearchHost, kOpenLeoButtonInvalidPath, + cur_prompt_count); + // No permission prompt should be shown. + ClickOpenLeoAndCheckLeoOpenedAndNavigationCancelled( + FROM_HERE, cur_prompt_count, false, kOpenLeoButtonInvalidPath); +} + +IN_PROC_BROWSER_TEST_F(AIChatBraveSearchThrottleBrowserTest, + OpenLeo_NotBraveSearchURL) { + // The behavior should be the same as without the throttle. + NavigateToTestPage(FROM_HERE, "brave.com", kOpenLeoButtonValidPath, 0); + content::TestNavigationObserver observer(GetActiveWebContents()); + ClickOpenLeoButton(); + observer.Wait(); + + EXPECT_FALSE(IsLeoOpened()); + EXPECT_EQ(0, prompt_factory_->show_count()); + EXPECT_TRUE(observer.last_navigation_succeeded()); + EXPECT_EQ(observer.last_navigation_url().path_piece(), kLeoPath); + EXPECT_EQ(GetActiveWebContents()->GetLastCommittedURL().path_piece(), + kLeoPath); +} diff --git a/browser/ai_chat/page_content_fetcher_browsertest.cc b/browser/ai_chat/page_content_fetcher_browsertest.cc index 8d6cfdcd19b5..b619358a8adc 100644 --- a/browser/ai_chat/page_content_fetcher_browsertest.cc +++ b/browser/ai_chat/page_content_fetcher_browsertest.cc @@ -133,6 +133,22 @@ class PageContentFetcherBrowserTest : public InProcessBrowserTest { run_loop.Run(); } + void ValidateOpenLeoButtonNonce(const base::Location& location, + const std::string& nonce, + bool expected_is_valid) { + SCOPED_TRACE(testing::Message() << location.ToString()); + base::RunLoop run_loop; + page_content_fetcher_ = + std::make_unique(GetActiveWebContents()); + page_content_fetcher_->ValidateOpenLeoButtonNonce( + nonce, base::BindLambdaForTesting( + [&run_loop, expected_is_valid](bool is_valid) { + EXPECT_EQ(expected_is_valid, is_valid); + run_loop.Quit(); + })); + run_loop.Run(); + } + // Handles returning a .patch file if the user is on a github.com pull request void SetGithubInterceptor() { GURL expected_patch_url = @@ -269,3 +285,39 @@ IN_PROC_BROWSER_TEST_F(PageContentFetcherBrowserTest, GetSearchSummarizerKey) { GetSearchSummarizerKey(FROM_HERE, expected_result); } } + +IN_PROC_BROWSER_TEST_F(PageContentFetcherBrowserTest, + ValidateOpenLeoButtonNonce) { + // Test no open Leo button with continue-with-leo ID present. + GURL url = https_server_.GetURL("a.com", "/open_leo_button.html"); + NavigateURL(url); + ValidateOpenLeoButtonNonce(FROM_HERE, "5566", false); + + // Test valid case. + NavigateURL(url); + ASSERT_TRUE(content::ExecJs(GetActiveWebContents()->GetPrimaryMainFrame(), + "document.getElementById('valid').setAttribute('" + "id', 'continue-with-leo')")); + ValidateOpenLeoButtonNonce(FROM_HERE, "5566", true); + + // The pass in nonce should match with continue-with-leo URL in href. + ValidateOpenLeoButtonNonce(FROM_HERE, "7788", false); + + // Test invalid cases. + std::vector invalid_cases = { + "invalid", "not-a-tag", "no-href", "no-nonce", + "empty-nonce", "empty-nonce2", "empty-nonce3", "empty-nonce4", + "empty-nonce5", "empty-nonce6", "not-https-url", "not-search-url", + "not-open-leo-url"}; + + for (const auto& invalid_case : invalid_cases) { + SCOPED_TRACE(testing::Message() << "Invalid case: " << invalid_case); + NavigateURL(url); + ASSERT_TRUE(content::ExecJs(GetActiveWebContents()->GetPrimaryMainFrame(), + base::ReplaceStringPlaceholders( + "document.getElementById('$1')." + "setAttribute('id', 'continue-with-leo')", + {invalid_case}, nullptr))); + ValidateOpenLeoButtonNonce(FROM_HERE, "5566", false); + } +} diff --git a/browser/brave_content_browser_client.cc b/browser/brave_content_browser_client.cc index a37795802975..b0ac05d4bbe7 100644 --- a/browser/brave_content_browser_client.cc +++ b/browser/brave_content_browser_client.cc @@ -156,7 +156,9 @@ using extensions::ChromeContentBrowserClientExtensionsPart; #endif #if BUILDFLAG(ENABLE_AI_CHAT) +#include "brave/browser/ai_chat/ai_chat_service_factory.h" #include "brave/browser/ui/webui/ai_chat/ai_chat_ui.h" +#include "brave/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h" #include "brave/components/ai_chat/content/browser/ai_chat_tab_helper.h" #include "brave/components/ai_chat/content/browser/ai_chat_throttle.h" #include "brave/components/ai_chat/core/browser/utils.h" @@ -164,6 +166,9 @@ using extensions::ChromeContentBrowserClientExtensionsPart; #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h" #include "brave/components/ai_chat/core/common/mojom/page_content_extractor.mojom.h" #include "brave/components/ai_chat/core/common/mojom/settings_helper.mojom.h" +#if !BUILDFLAG(IS_ANDROID) +#include "brave/browser/ui/ai_chat/ai_chat_brave_search_throttle_delegate_impl.h" +#endif #if BUILDFLAG(IS_ANDROID) #include "brave/components/ai_chat/core/browser/android/ai_chat_iap_subscription_android.h" #endif @@ -1267,6 +1272,21 @@ BraveContentBrowserClient::CreateThrottlesForNavigation( throttles.push_back(std::move(ai_chat_throttle)); } } + + if (Profile::FromBrowserContext(context)->IsRegularProfile()) { + std::unique_ptr delegate; +#if !BUILDFLAG(IS_ANDROID) + delegate = + std::make_unique(); +#endif + + if (auto ai_chat_brave_search_throttle = + ai_chat::AIChatBraveSearchThrottle::MaybeCreateThrottleFor( + std::move(delegate), handle, + ai_chat::AIChatServiceFactory::GetForBrowserContext(context))) { + throttles.push_back(std::move(ai_chat_brave_search_throttle)); + } + } #endif // ENABLE_AI_CHAT return throttles; diff --git a/browser/ui/BUILD.gn b/browser/ui/BUILD.gn index 08f53da439b8..7c71b264b608 100644 --- a/browser/ui/BUILD.gn +++ b/browser/ui/BUILD.gn @@ -108,6 +108,13 @@ source_set("ui") { "webui/ai_chat/ai_chat_ui_page_handler.h", ] + if (!is_android && !is_ios) { + sources += [ + "ai_chat/ai_chat_brave_search_throttle_delegate_impl.cc", + "ai_chat/ai_chat_brave_search_throttle_delegate_impl.h", + ] + } + deps += [ "//brave/browser/ai_chat" ] if (enable_print_preview) { diff --git a/browser/ui/ai_chat/ai_chat_brave_search_throttle_delegate_impl.cc b/browser/ui/ai_chat/ai_chat_brave_search_throttle_delegate_impl.cc new file mode 100644 index 000000000000..388e417efebd --- /dev/null +++ b/browser/ui/ai_chat/ai_chat_brave_search_throttle_delegate_impl.cc @@ -0,0 +1,19 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/browser/ui/ai_chat/ai_chat_brave_search_throttle_delegate_impl.h" + +#include "brave/browser/ui/sidebar/sidebar_utils.h" +#include "brave/components/sidebar/browser/sidebar_item.h" + +namespace ai_chat { + +void AIChatBraveSearchThrottleDelegateImpl::OpenLeo( + content::WebContents* web_contents) { + ActivatePanelItem(web_contents, + sidebar::SidebarItem::BuiltInItemType::kChatUI); +} + +} // namespace ai_chat diff --git a/browser/ui/ai_chat/ai_chat_brave_search_throttle_delegate_impl.h b/browser/ui/ai_chat/ai_chat_brave_search_throttle_delegate_impl.h new file mode 100644 index 000000000000..4749ae20bfce --- /dev/null +++ b/browser/ui/ai_chat/ai_chat_brave_search_throttle_delegate_impl.h @@ -0,0 +1,34 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_BROWSER_UI_AI_CHAT_AI_CHAT_BRAVE_SEARCH_THROTTLE_DELEGATE_IMPL_H_ +#define BRAVE_BROWSER_UI_AI_CHAT_AI_CHAT_BRAVE_SEARCH_THROTTLE_DELEGATE_IMPL_H_ + +#include "brave/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h" + +namespace content { +class WebContents; +} + +namespace ai_chat { + +class AIChatBraveSearchThrottleDelegateImpl + : public AIChatBraveSearchThrottle::Delegate { + public: + AIChatBraveSearchThrottleDelegateImpl() = default; + ~AIChatBraveSearchThrottleDelegateImpl() override = default; + + // AIChatBraveSearchThrottle::Delegate: + void OpenLeo(content::WebContents* web_contents) override; + + AIChatBraveSearchThrottleDelegateImpl( + const AIChatBraveSearchThrottleDelegateImpl&) = delete; + AIChatBraveSearchThrottleDelegateImpl& operator=( + const AIChatBraveSearchThrottleDelegateImpl&) = delete; +}; + +} // namespace ai_chat + +#endif // BRAVE_BROWSER_UI_AI_CHAT_AI_CHAT_BRAVE_SEARCH_THROTTLE_DELEGATE_IMPL_H_ diff --git a/browser/ui/ai_chat/ai_chat_tab_helper_unittest.cc b/browser/ui/ai_chat/ai_chat_tab_helper_unittest.cc index afbc788cbf46..ff675262be4e 100644 --- a/browser/ui/ai_chat/ai_chat_tab_helper_unittest.cc +++ b/browser/ui/ai_chat/ai_chat_tab_helper_unittest.cc @@ -51,6 +51,11 @@ class MockPageContentFetcher GetSearchSummarizerKey, (mojom::PageContentExtractor::GetSearchSummarizerKeyCallback), (override)); + MOCK_METHOD(void, + ValidateOpenLeoButtonNonce, + (const std::string&, + mojom::PageContentExtractor::ValidateOpenLeoButtonNonceCallback), + (override)); }; class MockAssociatedContentObserver : public AssociatedContentDriver::Observer { diff --git a/browser/ui/sidebar/sidebar_utils.cc b/browser/ui/sidebar/sidebar_utils.cc index 58af87379838..a4c65ff12f63 100644 --- a/browser/ui/sidebar/sidebar_utils.cc +++ b/browser/ui/sidebar/sidebar_utils.cc @@ -22,6 +22,7 @@ #include "chrome/browser/profiles/profile.h" #include "chrome/browser/search/search.h" #include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_finder.h" #include "chrome/browser/ui/tabs/tab_strip_model.h" #include "chrome/browser/ui/views/side_panel/side_panel_entry_id.h" #include "chrome/browser/ui/webui/new_tab_page/new_tab_page_ui.h" @@ -276,4 +277,24 @@ SidebarService::ShowSidebarOption GetDefaultShowSidebarOption( return ShowSidebarOption::kShowNever; } +void ActivatePanelItem(content::WebContents* web_contents, + SidebarItem::BuiltInItemType panel_item) { + if (!web_contents) { + return; + } + + auto* browser = chrome::FindBrowserWithTab(web_contents); + if (!browser) { + return; + } + + auto* sidebar_controller = + static_cast(browser)->sidebar_controller(); + if (!sidebar_controller) { + return; + } + + sidebar_controller->ActivatePanelItem(panel_item); +} + } // namespace sidebar diff --git a/browser/ui/sidebar/sidebar_utils.h b/browser/ui/sidebar/sidebar_utils.h index a58fce6aa498..4a45640bed2a 100644 --- a/browser/ui/sidebar/sidebar_utils.h +++ b/browser/ui/sidebar/sidebar_utils.h @@ -17,6 +17,10 @@ class GURL; class PrefService; enum class SidePanelEntryId; +namespace content { +class WebContents; +} + namespace sidebar { bool CanUseSidebar(Browser* browser); @@ -44,6 +48,10 @@ bool IsDisabledItemForGuest(SidebarItem::BuiltInItemType type); SidebarService::ShowSidebarOption GetDefaultShowSidebarOption( version_info::Channel channel); + +void ActivatePanelItem(content::WebContents* web_contents, + SidebarItem::BuiltInItemType panel_item); + } // namespace sidebar #endif // BRAVE_BROWSER_UI_SIDEBAR_SIDEBAR_UTILS_H_ diff --git a/components/ai_chat/content/browser/BUILD.gn b/components/ai_chat/content/browser/BUILD.gn index 1e3a609d705f..5f771501d52c 100644 --- a/components/ai_chat/content/browser/BUILD.gn +++ b/components/ai_chat/content/browser/BUILD.gn @@ -10,6 +10,8 @@ assert(enable_ai_chat) static_library("browser") { sources = [ + "ai_chat_brave_search_throttle.cc", + "ai_chat_brave_search_throttle.h", "ai_chat_tab_helper.cc", "ai_chat_tab_helper.h", "ai_chat_throttle.cc", diff --git a/components/ai_chat/content/browser/ai_chat_brave_search_throttle.cc b/components/ai_chat/content/browser/ai_chat_brave_search_throttle.cc new file mode 100644 index 000000000000..0e97c5e9b4a1 --- /dev/null +++ b/components/ai_chat/content/browser/ai_chat_brave_search_throttle.cc @@ -0,0 +1,158 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h" + +#include +#include + +#include "base/check.h" +#include "base/functional/bind.h" +#include "brave/components/ai_chat/content/browser/ai_chat_tab_helper.h" +#include "brave/components/ai_chat/content/browser/page_content_fetcher.h" +#include "brave/components/ai_chat/core/browser/conversation_handler.h" +#include "brave/components/ai_chat/core/browser/utils.h" +#include "brave/components/ai_chat/core/common/utils.h" +#include "components/user_prefs/user_prefs.h" +#include "content/public/browser/browser_context.h" +#include "content/public/browser/navigation_handle.h" +#include "content/public/browser/permission_controller.h" +#include "content/public/browser/permission_request_description.h" +#include "content/public/browser/web_contents.h" + +namespace ai_chat { + +// static +std::unique_ptr +AIChatBraveSearchThrottle::MaybeCreateThrottleFor( + std::unique_ptr delegate, + content::NavigationHandle* navigation_handle, + AIChatService* ai_chat_service) { + auto* web_contents = navigation_handle->GetWebContents(); + if (!web_contents) { + return nullptr; + } + + if (!delegate || !ai_chat_service || + !ai_chat::IsAIChatEnabled( + user_prefs::UserPrefs::Get(web_contents->GetBrowserContext())) || + !::ai_chat::IsOpenLeoButtonFromBraveSearchURL( + navigation_handle->GetURL())) { + return nullptr; + } + + return std::make_unique( + std::move(delegate), navigation_handle, ai_chat_service); +} + +AIChatBraveSearchThrottle::AIChatBraveSearchThrottle( + std::unique_ptr delegate, + content::NavigationHandle* handle, + AIChatService* ai_chat_service) + : content::NavigationThrottle(handle), + delegate_(std::move(delegate)), + ai_chat_service_(ai_chat_service) { + CHECK(delegate_); + CHECK(ai_chat_service_); +} + +AIChatBraveSearchThrottle::~AIChatBraveSearchThrottle() = default; + +AIChatBraveSearchThrottle::ThrottleCheckResult +AIChatBraveSearchThrottle::WillStartRequest() { + content::WebContents* web_contents = navigation_handle()->GetWebContents(); + if (!web_contents || !navigation_handle()->IsInPrimaryMainFrame() || + !IsOpenLeoButtonFromBraveSearchURL(navigation_handle()->GetURL()) || + !IsBraveSearchURL(web_contents->GetLastCommittedURL())) { + return content::NavigationThrottle::PROCEED; + } + + // Check if nonce in HTML tag matches the one in the URL. + AIChatTabHelper* helper = + ai_chat::AIChatTabHelper::FromWebContents(web_contents); + if (!helper) { + return content::NavigationThrottle::PROCEED; + } + + helper->ValidateOpenLeoButtonNonce( + navigation_handle()->GetURL().ref(), + base::BindOnce(&AIChatBraveSearchThrottle::OnNonceValidationResult, + weak_factory_.GetWeakPtr())); + return content::NavigationThrottle::DEFER; +} + +void AIChatBraveSearchThrottle::OpenLeoWithStagedConversions() { + content::WebContents* web_contents = navigation_handle()->GetWebContents(); + if (!web_contents) { + return; + } + + ai_chat::AIChatTabHelper* helper = + ai_chat::AIChatTabHelper::FromWebContents(web_contents); + if (!helper) { + return; + } + + ConversationHandler* conversation = + ai_chat_service_->GetOrCreateConversationHandlerForContent( + helper->GetContentId(), helper->GetWeakPtr()); + if (!conversation) { + return; + } + + delegate_->OpenLeo(web_contents); + // Trigger the fetch of staged conversations from Brave Search. + conversation->MaybeFetchOrClearContentStagedConversation(); +} + +void AIChatBraveSearchThrottle::OnNonceValidationResult(bool valid) { + if (!valid) { + CancelDeferredNavigation(content::NavigationThrottle::CANCEL); + return; + } + + // Check if the user has granted permission to open AI Chat. + content::WebContents* web_contents = navigation_handle()->GetWebContents(); + if (!web_contents) { + CancelDeferredNavigation(content::NavigationThrottle::CANCEL); + return; + } + + content::RenderFrameHost* rfh = web_contents->GetPrimaryMainFrame(); + content::PermissionController* permission_controller = + web_contents->GetBrowserContext()->GetPermissionController(); + content::PermissionResult permission_status = + permission_controller->GetPermissionResultForCurrentDocument( + blink::PermissionType::BRAVE_AI_CHAT, rfh); + + if (permission_status.status == content::PermissionStatus::DENIED) { + CancelDeferredNavigation(content::NavigationThrottle::CANCEL); + } else if (permission_status.status == content::PermissionStatus::GRANTED) { + OpenLeoWithStagedConversions(); + CancelDeferredNavigation(content::NavigationThrottle::CANCEL); + } else { // ask + permission_controller->RequestPermissionFromCurrentDocument( + rfh, + content::PermissionRequestDescription( + blink::PermissionType::BRAVE_AI_CHAT, /*user_gesture=*/true), + base::BindOnce(&AIChatBraveSearchThrottle::OnPermissionPromptResult, + weak_factory_.GetWeakPtr())); + } +} + +void AIChatBraveSearchThrottle::OnPermissionPromptResult( + content::PermissionStatus status) { + if (status == content::PermissionStatus::GRANTED) { + OpenLeoWithStagedConversions(); + } + + CancelDeferredNavigation(content::NavigationThrottle::CANCEL); +} + +const char* AIChatBraveSearchThrottle::GetNameForLogging() { + return "AIChatBraveSearchThrottle"; +} + +} // namespace ai_chat diff --git a/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h b/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h new file mode 100644 index 000000000000..97135c95730e --- /dev/null +++ b/components/ai_chat/content/browser/ai_chat_brave_search_throttle.h @@ -0,0 +1,75 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_COMPONENTS_AI_CHAT_CONTENT_BROWSER_AI_CHAT_BRAVE_SEARCH_THROTTLE_H_ +#define BRAVE_COMPONENTS_AI_CHAT_CONTENT_BROWSER_AI_CHAT_BRAVE_SEARCH_THROTTLE_H_ + +#include + +#include "base/memory/raw_ptr.h" +#include "base/memory/weak_ptr.h" +#include "brave/components/ai_chat/core/browser/ai_chat_service.h" +#include "content/public/browser/navigation_throttle.h" +#include "content/public/browser/permission_result.h" + +namespace content { +class WebContents; +} + +namespace ai_chat { + +// A network throttle which intercepts Brave Search requests. +// Currently the only use case is to intercept requests to open Leo AI chat, so +// it is only created when navigating to open Leo button URL from Brave Search. +// It could be extended to other Brave Search URLs in the future. +// +// For Open Leo feature, we check: +// 1) If AI chat is enabled. +// 2) If the request is from Brave Search and is trying to navigate to open Leo +// button URL. +// 3) If the nonce property in the a tag element is equal to the one in url ref. +// 4) If the user has granted permission to open Leo. +// The navigation to the specific Open Leo URL will be cancelled, and Leo AI +// chat will be opened if all the above conditions are met. +class AIChatBraveSearchThrottle : public content::NavigationThrottle { + public: + class Delegate { + public: + virtual void OpenLeo(content::WebContents* web_contents) {} + Delegate() = default; + virtual ~Delegate() = default; + + Delegate(const Delegate&) = delete; + Delegate& operator=(const Delegate&) = delete; + }; + + explicit AIChatBraveSearchThrottle(std::unique_ptr delegate, + content::NavigationHandle* handle, + AIChatService* ai_chat_service); + ~AIChatBraveSearchThrottle() override; + + static std::unique_ptr MaybeCreateThrottleFor( + std::unique_ptr delegate, + content::NavigationHandle* navigation_handle, + AIChatService* ai_chat_service); + + ThrottleCheckResult WillStartRequest() override; + const char* GetNameForLogging() override; + + private: + void OnNonceValidationResult(bool valid); + void OnPermissionPromptResult(blink::mojom::PermissionStatus status); + + void OpenLeoWithStagedConversions(); + + std::unique_ptr delegate_; + const raw_ptr ai_chat_service_ = nullptr; + + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace ai_chat + +#endif // BRAVE_COMPONENTS_AI_CHAT_CONTENT_BROWSER_AI_CHAT_BRAVE_SEARCH_THROTTLE_H_ diff --git a/components/ai_chat/content/browser/ai_chat_tab_helper.cc b/components/ai_chat/content/browser/ai_chat_tab_helper.cc index 46b728ea0ea4..c454141eb656 100644 --- a/components/ai_chat/content/browser/ai_chat_tab_helper.cc +++ b/components/ai_chat/content/browser/ai_chat_tab_helper.cc @@ -18,7 +18,6 @@ #include "brave/components/ai_chat/content/browser/page_content_fetcher.h" #include "brave/components/ai_chat/content/browser/pdf_utils.h" #include "brave/components/ai_chat/core/browser/associated_content_driver.h" -#include "brave/components/ai_chat/core/browser/constants.h" #include "brave/components/ai_chat/core/browser/utils.h" #include "brave/components/ai_chat/core/common/mojom/page_content_extractor.mojom.h" #include "components/favicon/content/content_favicon_driver.h" @@ -414,6 +413,13 @@ void AIChatTabHelper::GetSearchSummarizerKey( page_content_fetcher_delegate_->GetSearchSummarizerKey(std::move(callback)); } +void AIChatTabHelper::ValidateOpenLeoButtonNonce( + const std::string& nonce, + mojom::PageContentExtractor::ValidateOpenLeoButtonNonceCallback callback) { + page_content_fetcher_delegate_->ValidateOpenLeoButtonNonce( + nonce, std::move(callback)); +} + WEB_CONTENTS_USER_DATA_KEY_IMPL(AIChatTabHelper); } // namespace ai_chat diff --git a/components/ai_chat/content/browser/ai_chat_tab_helper.h b/components/ai_chat/content/browser/ai_chat_tab_helper.h index f4309abc9399..631875e7c0ed 100644 --- a/components/ai_chat/content/browser/ai_chat_tab_helper.h +++ b/components/ai_chat/content/browser/ai_chat_tab_helper.h @@ -9,6 +9,7 @@ #include #include #include +#include #include "base/functional/callback_forward.h" #include "base/memory/raw_ptr.h" @@ -74,6 +75,13 @@ class AIChatTabHelper : public content::WebContentsObserver, // Attempts to find a search summarizer key for the page. virtual void GetSearchSummarizerKey( GetSearchSummarizerKeyCallback callback) = 0; + + // Fetches the nonce for the OpenLeo button from the page HTML and validate + // if it matches the href URL and the passed in nonce. + virtual void ValidateOpenLeoButtonNonce( + const std::string& nonce, + mojom::PageContentExtractor::ValidateOpenLeoButtonNonceCallback + callback) = 0; }; AIChatTabHelper(const AIChatTabHelper&) = delete; @@ -97,6 +105,10 @@ class AIChatTabHelper : public content::WebContentsObserver, // mojom::PageContentExtractorHost void OnInterceptedPageContentChanged() override; + void ValidateOpenLeoButtonNonce( + const std::string& nonce, + mojom::PageContentExtractor::ValidateOpenLeoButtonNonceCallback callback); + private: friend class content::WebContentsUserData; friend class ::AIChatUIBrowserTest; diff --git a/components/ai_chat/content/browser/page_content_fetcher.cc b/components/ai_chat/content/browser/page_content_fetcher.cc index 9a29de3f306a..76cb19cbe87f 100644 --- a/components/ai_chat/content/browser/page_content_fetcher.cc +++ b/components/ai_chat/content/browser/page_content_fetcher.cc @@ -135,6 +135,21 @@ class PageContentFetcherInternal { content_extractor_->GetSearchSummarizerKey(std::move(callback)); } + void ValidateOpenLeoButtonNonce( + const std::string& nonce, + mojo::Remote content_extractor, + mojom::PageContentExtractor::ValidateOpenLeoButtonNonceCallback + callback) { + content_extractor_ = std::move(content_extractor); + if (!content_extractor_) { + DeleteSelf(); + return; + } + content_extractor_.set_disconnect_handler(base::BindOnce( + &PageContentFetcherInternal::DeleteSelf, base::Unretained(this))); + content_extractor_->ValidateOpenLeoButtonNonce(nonce, std::move(callback)); + } + void StartGithub( GURL patch_url, FetchPageContentCallback callback) { @@ -460,4 +475,18 @@ void PageContentFetcher::GetSearchSummarizerKey( fetcher->GetSearchSummarizerKey(std::move(extractor), std::move(callback)); } +void PageContentFetcher::ValidateOpenLeoButtonNonce( + const std::string& nonce, + mojom::PageContentExtractor::ValidateOpenLeoButtonNonceCallback callback) { + auto* primary_rfh = web_contents_->GetPrimaryMainFrame(); + DCHECK(primary_rfh->IsRenderFrameLive()); + + auto* fetcher = new PageContentFetcherInternal(nullptr); + mojo::Remote extractor; + primary_rfh->GetRemoteInterfaces()->GetInterface( + extractor.BindNewPipeAndPassReceiver()); + fetcher->ValidateOpenLeoButtonNonce(nonce, std::move(extractor), + std::move(callback)); +} + } // namespace ai_chat diff --git a/components/ai_chat/content/browser/page_content_fetcher.h b/components/ai_chat/content/browser/page_content_fetcher.h index b339c68589b5..81ec44cbc1e8 100644 --- a/components/ai_chat/content/browser/page_content_fetcher.h +++ b/components/ai_chat/content/browser/page_content_fetcher.h @@ -39,6 +39,11 @@ class PageContentFetcher : public AIChatTabHelper::PageContentFetcherDelegate { mojom::PageContentExtractor::GetSearchSummarizerKeyCallback callback) override; + void ValidateOpenLeoButtonNonce( + const std::string& nonce, + mojom::PageContentExtractor::ValidateOpenLeoButtonNonceCallback callback) + override; + void SetURLLoaderFactoryForTesting( scoped_refptr url_loader_factory) { url_loader_factory_ = url_loader_factory; diff --git a/components/ai_chat/core/browser/associated_content_driver.cc b/components/ai_chat/core/browser/associated_content_driver.cc index a63dc2799733..a43cee862e61 100644 --- a/components/ai_chat/core/browser/associated_content_driver.cc +++ b/components/ai_chat/core/browser/associated_content_driver.cc @@ -19,9 +19,9 @@ #include "base/strings/string_util.h" #include "brave/brave_domains/service_domains.h" #include "brave/components/ai_chat/core/browser/brave_search_responses.h" -#include "brave/components/ai_chat/core/browser/constants.h" #include "brave/components/ai_chat/core/browser/conversation_handler.h" #include "brave/components/ai_chat/core/browser/utils.h" +#include "brave/components/ai_chat/core/common/constants.h" #include "net/base/url_util.h" #include "net/traffic_annotation/network_traffic_annotation.h" #include "services/network/public/cpp/shared_url_loader_factory.h" diff --git a/components/ai_chat/core/browser/constants.h b/components/ai_chat/core/browser/constants.h index 6eaab67f9f30..25ce8bca9952 100644 --- a/components/ai_chat/core/browser/constants.h +++ b/components/ai_chat/core/browser/constants.h @@ -36,8 +36,6 @@ constexpr float kMaxContentLengthThreshold = 0.6f; constexpr size_t kReservedTokensForPrompt = 300; constexpr size_t kReservedTokensForMaxNewTokens = 400; -inline constexpr char kBraveSearchURLPrefix[] = "search"; - } // namespace ai_chat #endif // BRAVE_COMPONENTS_AI_CHAT_CORE_BROWSER_CONSTANTS_H_ diff --git a/components/ai_chat/core/browser/conversation_handler.cc b/components/ai_chat/core/browser/conversation_handler.cc index 9a8220542475..5984ef5cee96 100644 --- a/components/ai_chat/core/browser/conversation_handler.cc +++ b/components/ai_chat/core/browser/conversation_handler.cc @@ -1026,23 +1026,16 @@ void ConversationHandler::MaybeFetchOrClearContentStagedConversation() { if (!can_check_for_staged_conversation) { // Clear any staged conversation entries since user might have unassociated // content with this conversation - if (chat_history_.empty()) { - return; - } - - const auto& last_turn = chat_history_.back(); - if (last_turn->from_brave_search_SERP) { - chat_history_.clear(); // Clear the staged entries. + size_t num_entries = chat_history_.size(); + std::erase_if(chat_history_, [](const mojom::ConversationTurnPtr& turn) { + return turn->from_brave_search_SERP; + }); + if (num_entries != chat_history_.size()) { OnHistoryUpdate(); } return; } - // Can only have staged entries at the start of a conversation. - if (!chat_history_.empty()) { - return; - } - associated_content_delegate_->GetStagedEntriesFromContent( base::BindOnce(&ConversationHandler::OnGetStagedEntriesFromContent, weak_ptr_factory_.GetWeakPtr())); @@ -1051,11 +1044,16 @@ void ConversationHandler::MaybeFetchOrClearContentStagedConversation() { void ConversationHandler::OnGetStagedEntriesFromContent( const std::optional>& entries) { // Check if all requirements are still met. - if (!entries || !chat_history_.empty() || !IsContentAssociationPossible() || + if (is_request_in_progress_ || !entries || !IsContentAssociationPossible() || !should_send_page_contents_ || !ai_chat_service_->HasUserOptedIn()) { return; } + // Clear previous staged entries. + std::erase_if(chat_history_, [](const mojom::ConversationTurnPtr& turn) { + return turn->from_brave_search_SERP; + }); + // Add the query & summary pairs to the conversation history and call // OnHistoryUpdate to update UI. for (const auto& entry : *entries) { diff --git a/components/ai_chat/core/browser/conversation_handler.h b/components/ai_chat/core/browser/conversation_handler.h index 1c7292d4a6cc..96d696d5dab2 100644 --- a/components/ai_chat/core/browser/conversation_handler.h +++ b/components/ai_chat/core/browser/conversation_handler.h @@ -217,6 +217,10 @@ class ConversationHandler : public mojom::ConversationHandler, void OnFaviconImageDataChanged(); void OnUserOptedIn(); + // Some associated content may provide some conversation that the user wants + // to continue, e.g. Brave Search. + void MaybeFetchOrClearContentStagedConversation(); + base::WeakPtr GetWeakPtr() { return weak_ptr_factory_.GetWeakPtr(); } @@ -278,9 +282,6 @@ class ConversationHandler : public mojom::ConversationHandler, bool is_video, std::string invalidation_token); - // Some associated content may provide some conversation that the user wants - // to continue, e.g. Brave Search. - void MaybeFetchOrClearContentStagedConversation(); void OnGetStagedEntriesFromContent( const std::optional>& entries); diff --git a/components/ai_chat/core/browser/conversation_handler_unittest.cc b/components/ai_chat/core/browser/conversation_handler_unittest.cc index 008e7fb507cb..6498bf085574 100644 --- a/components/ai_chat/core/browser/conversation_handler_unittest.cc +++ b/components/ai_chat/core/browser/conversation_handler_unittest.cc @@ -1092,11 +1092,11 @@ TEST_F(ConversationHandlerUnitTest, task_environment_.RunUntilIdle(); testing::Mock::VerifyAndClearExpectations(associated_content_.get()); - // Verify that no re-fetch happens when new client connects + // Verify that fetch happens when another client connects. client.Disconnect(); task_environment_.RunUntilIdle(); EXPECT_FALSE(conversation_handler_->IsAnyClientConnected()); - EXPECT_CALL(*associated_content_, GetStagedEntriesFromContent).Times(0); + EXPECT_CALL(*associated_content_, GetStagedEntriesFromContent).Times(1); NiceMock client2(conversation_handler_.get()); task_environment_.RunUntilIdle(); testing::Mock::VerifyAndClearExpectations(associated_content_.get()); diff --git a/components/ai_chat/core/browser/utils.cc b/components/ai_chat/core/browser/utils.cc index ea31beb668d9..184eb43d2063 100644 --- a/components/ai_chat/core/browser/utils.cc +++ b/components/ai_chat/core/browser/utils.cc @@ -5,6 +5,10 @@ #include "brave/components/ai_chat/core/browser/utils.h" +#include +#include +#include + #include "base/containers/fixed_flat_set.h" #include "base/functional/bind.h" #include "base/no_destructor.h" @@ -14,6 +18,7 @@ #include "base/time/time.h" #include "brave/brave_domains/service_domains.h" #include "brave/components/ai_chat/core/browser/constants.h" +#include "brave/components/ai_chat/core/common/constants.h" #include "brave/components/ai_chat/core/common/features.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h" #include "brave/components/ai_chat/core/common/pref_names.h" diff --git a/components/ai_chat/core/common/BUILD.gn b/components/ai_chat/core/common/BUILD.gn index a43344eac582..399445287a71 100644 --- a/components/ai_chat/core/common/BUILD.gn +++ b/components/ai_chat/core/common/BUILD.gn @@ -12,16 +12,21 @@ component("common") { defines = [ "IS_AI_CHAT_COMMON_IMPL" ] sources = [ + "constants.h", "features.cc", "features.h", "pref_names.cc", "pref_names.h", + "utils.cc", + "utils.h", ] deps = [ "//base", + "//brave/brave_domains", "//brave/components/ai_chat/core/common/buildflags:buildflags", "//components/prefs", + "//url", ] } diff --git a/components/ai_chat/core/common/constants.h b/components/ai_chat/core/common/constants.h new file mode 100644 index 000000000000..a3155a6088f4 --- /dev/null +++ b/components/ai_chat/core/common/constants.h @@ -0,0 +1,15 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_CONSTANTS_H_ +#define BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_CONSTANTS_H_ + +namespace ai_chat { + +inline constexpr char kBraveSearchURLPrefix[] = "search"; + +} // namespace ai_chat + +#endif // BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_CONSTANTS_H_ diff --git a/components/ai_chat/core/common/mojom/page_content_extractor.mojom b/components/ai_chat/core/common/mojom/page_content_extractor.mojom index ad7ec91d38dc..6cc0d3f63651 100644 --- a/components/ai_chat/core/common/mojom/page_content_extractor.mojom +++ b/components/ai_chat/core/common/mojom/page_content_extractor.mojom @@ -32,6 +32,11 @@ interface PageContentExtractor { // Get summarizer-key meta tag from Brave Search SERP if it exists. // This should only be called when the last commited URL is Brave Search SERP. GetSearchSummarizerKey() => (string? key); + + // Given the nonce value from the URL ref, validate the nonce matches both + // the nonce in the href URL and the nonce attribute value in the + // continue-with-leo HTML anchor element (open Leo button). + ValidateOpenLeoButtonNonce(string nonce) => (bool valid); }; // Allows the renderer to notify the browser process of meaningful changes to diff --git a/components/ai_chat/core/common/utils.cc b/components/ai_chat/core/common/utils.cc new file mode 100644 index 000000000000..49e6a094c3d6 --- /dev/null +++ b/components/ai_chat/core/common/utils.cc @@ -0,0 +1,25 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/components/ai_chat/core/common/utils.h" + +#include "brave/brave_domains/service_domains.h" +#include "brave/components/ai_chat/core/common/constants.h" +#include "url/gurl.h" +#include "url/url_constants.h" + +namespace ai_chat { + +bool IsBraveSearchURL(const GURL& url) { + return url.is_valid() && url.SchemeIs(url::kHttpsScheme) && + url.host_piece() == + brave_domains::GetServicesDomain(kBraveSearchURLPrefix); +} + +bool IsOpenLeoButtonFromBraveSearchURL(const GURL& url) { + return IsBraveSearchURL(url) && url.path_piece() == "/leo"; +} + +} // namespace ai_chat diff --git a/components/ai_chat/core/common/utils.h b/components/ai_chat/core/common/utils.h new file mode 100644 index 000000000000..32a00d0eef7b --- /dev/null +++ b/components/ai_chat/core/common/utils.h @@ -0,0 +1,23 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_UTILS_H_ +#define BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_UTILS_H_ + +#include "base/component_export.h" + +class GURL; + +namespace ai_chat { + +COMPONENT_EXPORT(AI_CHAT_COMMON) +bool IsBraveSearchURL(const GURL& url); + +COMPONENT_EXPORT(AI_CHAT_COMMON) +bool IsOpenLeoButtonFromBraveSearchURL(const GURL& url); + +} // namespace ai_chat + +#endif // BRAVE_COMPONENTS_AI_CHAT_CORE_COMMON_UTILS_H_ diff --git a/components/ai_chat/renderer/BUILD.gn b/components/ai_chat/renderer/BUILD.gn index b214f86b3aeb..7394baf8dd02 100644 --- a/components/ai_chat/renderer/BUILD.gn +++ b/components/ai_chat/renderer/BUILD.gn @@ -22,6 +22,7 @@ static_library("renderer") { deps = [ "//base", + "//brave/components/ai_chat/core/common", "//brave/components/ai_chat/core/common/mojom", "//content/public/renderer", "//gin", diff --git a/components/ai_chat/renderer/page_content_extractor.cc b/components/ai_chat/renderer/page_content_extractor.cc index 9444d32cef2a..6ec403e55527 100644 --- a/components/ai_chat/renderer/page_content_extractor.cc +++ b/components/ai_chat/renderer/page_content_extractor.cc @@ -3,14 +3,14 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this file, // You can obtain one at https://mozilla.org/MPL/2.0/. +#include "brave/components/ai_chat/renderer/page_content_extractor.h" + #ifdef UNSAFE_BUFFERS_BUILD // TODO(https://github.com/brave/brave-browser/issues/41661): Remove this and // convert code to safer constructs. #pragma allow_unsafe_buffers #endif -#include "brave/components/ai_chat/renderer/page_content_extractor.h" - #include #include #include @@ -21,10 +21,13 @@ #include "base/containers/fixed_flat_set.h" #include "base/containers/span.h" #include "base/functional/bind.h" +#include "base/functional/callback.h" #include "base/memory/ptr_util.h" +#include "base/time/time.h" #include "base/values.h" #include "brave/components/ai_chat/core/common/mojom/page_content_extractor.mojom-shared.h" #include "brave/components/ai_chat/core/common/mojom/page_content_extractor.mojom.h" +#include "brave/components/ai_chat/core/common/utils.h" #include "brave/components/ai_chat/renderer/page_text_distilling.h" #include "brave/components/ai_chat/renderer/yt_util.h" #include "content/public/renderer/render_frame.h" @@ -318,4 +321,54 @@ void PageContentExtractor::GetSearchSummarizerKey( std::move(callback).Run(element.GetAttribute("content").Utf8()); } +void PageContentExtractor::ValidateOpenLeoButtonNonce( + const std::string& nonce, + mojom::PageContentExtractor::ValidateOpenLeoButtonNonceCallback callback) { + if (nonce.empty()) { + std::move(callback).Run(false); + return; + } + + auto element = render_frame()->GetWebFrame()->GetDocument().GetElementById( + "continue-with-leo"); + if (element.IsNull() || !element.HasHTMLTagName("a")) { + std::move(callback).Run(false); + return; + } + + GURL url(element.GetAttribute("href").Utf8()); + if (!IsOpenLeoButtonFromBraveSearchURL(url) || + !element.HasAttribute("nonce") || url.ref_piece() != nonce) { + std::move(callback).Run(false); + return; + } + + // Value of nonce property is not accessible via GetAttribute API, so we need + // to execute a script to get it. + blink::WebScriptSource source = + blink::WebScriptSource(blink::WebString::FromUTF8( + "document.getElementById('continue-with-leo').nonce")); + + auto on_script_executed = + [](const std::string& nonce, base::OnceCallback callback, + std::optional value, base::TimeTicks start_time) { + if (!value.has_value() || !value->is_string()) { + std::move(callback).Run(false); + return; + } + + std::move(callback).Run(value->GetString() == nonce); + }; + + render_frame()->GetWebFrame()->RequestExecuteScript( + isolated_world_id_, base::span_from_ref(source), + blink::mojom::UserActivationOption::kDoNotActivate, + blink::mojom::EvaluationTiming::kAsynchronous, + blink::mojom::LoadEventBlockingOption::kDoNotBlock, + base::BindOnce(on_script_executed, nonce, std::move(callback)), + blink::BackForwardCacheAware::kAllow, + blink::mojom::WantResultOption::kWantResult, + blink::mojom::PromiseResultOption::kAwait); +} + } // namespace ai_chat diff --git a/components/ai_chat/renderer/page_content_extractor.h b/components/ai_chat/renderer/page_content_extractor.h index d56e8889249a..d941532df49e 100644 --- a/components/ai_chat/renderer/page_content_extractor.h +++ b/components/ai_chat/renderer/page_content_extractor.h @@ -59,6 +59,10 @@ class PageContentExtractor void GetSearchSummarizerKey( mojom::PageContentExtractor::GetSearchSummarizerKeyCallback callback) override; + void ValidateOpenLeoButtonNonce( + const std::string& nonce, + mojom::PageContentExtractor::ValidateOpenLeoButtonNonceCallback callback) + override; // AIChatResourceSnifferThrottleDelegate void OnInterceptedPageContentChanged( diff --git a/test/data/leo/leo b/test/data/leo/leo new file mode 100644 index 000000000000..0354eeb28ea6 --- /dev/null +++ b/test/data/leo/leo @@ -0,0 +1,9 @@ + + + + Leo test + + +
I have spoken
+ + diff --git a/test/data/leo/open_leo_button.html b/test/data/leo/open_leo_button.html new file mode 100644 index 000000000000..c25c02559164 --- /dev/null +++ b/test/data/leo/open_leo_button.html @@ -0,0 +1,22 @@ + + + + Leo test + + + Open Leo + Open Leo +
+ Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + Open Leo + + diff --git a/test/data/leo/open_leo_button_invalid.html b/test/data/leo/open_leo_button_invalid.html new file mode 100644 index 000000000000..99574725e27f --- /dev/null +++ b/test/data/leo/open_leo_button_invalid.html @@ -0,0 +1,9 @@ + + + + Leo test + + + Open Leo + + diff --git a/test/data/leo/open_leo_button_valid.html b/test/data/leo/open_leo_button_valid.html new file mode 100644 index 000000000000..9b1a4f3f0dc4 --- /dev/null +++ b/test/data/leo/open_leo_button_valid.html @@ -0,0 +1,9 @@ + + + + Leo test + + + Open Leo + +