| // Copyright 2024 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "ash/scanner/scanner_action_handler.h" |
| |
| #include <cstddef> |
| #include <memory> |
| #include <optional> |
| #include <string> |
| #include <string_view> |
| #include <utility> |
| #include <variant> |
| |
| #include "ash/public/cpp/new_window_delegate.h" |
| #include "ash/scanner/scanner_command.h" |
| #include "ash/scanner/scanner_command_delegate.h" |
| #include "base/check.h" |
| #include "base/containers/span.h" |
| #include "base/containers/to_vector.h" |
| #include "base/files/file.h" |
| #include "base/files/file_path.h" |
| #include "base/files/scoped_temp_file.h" |
| #include "base/functional/bind.h" |
| #include "base/functional/callback.h" |
| #include "base/functional/callback_helpers.h" |
| #include "base/location.h" |
| #include "base/memory/weak_ptr.h" |
| #include "base/strings/escape.h" |
| #include "base/strings/strcat.h" |
| #include "base/task/task_traits.h" |
| #include "base/task/thread_pool.h" |
| #include "base/types/expected.h" |
| #include "components/drive/drive_api_util.h" |
| #include "components/drive/service/drive_api_service.h" |
| #include "components/drive/service/drive_service_interface.h" |
| #include "components/manta/proto/scanner.pb.h" |
| #include "google_apis/common/api_error_codes.h" |
| #include "google_apis/common/request_sender.h" |
| #include "google_apis/drive/drive_api_parser.h" |
| #include "google_apis/people/people_api_request_types.h" |
| #include "google_apis/people/people_api_requests.h" |
| #include "google_apis/people/people_api_response_types.h" |
| #include "third_party/abseil-cpp/absl/cleanup/cleanup.h" |
| #include "third_party/abseil-cpp/absl/functional/overload.h" |
| #include "ui/base/clipboard/clipboard_data.h" |
| #include "url/gurl.h" |
| |
| namespace ash { |
| |
| namespace { |
| |
| // The prefix that all `Person.resourceName`s from the People API should start |
| // with. |
| constexpr std::string_view kPersonResourceNamePrefix = "people/"; |
| // The path for the Contacts web UI that displays a Person. |
| constexpr std::string_view kPersonContactsWebUiPath = "/person/"; |
| |
| const GURL& GetCalendarEventTemplateUrl() { |
| // Required to delay the creation of this GURL to avoid hitting the |
| // `url::DoSchemeModificationPreamble` DCHECK. |
| static GURL kGoogleCalendarEventTemplateUrl( |
| "https://calendar.google.com/calendar/render?action=TEMPLATE"); |
| return kGoogleCalendarEventTemplateUrl; |
| } |
| |
| const GURL& GetGoogleContactsBaseUrl() { |
| static GURL kGoogleContactsBaseUrl("https://contacts.google.com/"); |
| return kGoogleContactsBaseUrl; |
| } |
| |
| GURL GetCalendarEventUrl(const manta::proto::NewEventAction& event) { |
| std::string query = GetCalendarEventTemplateUrl().query(); |
| CHECK(!query.empty()); |
| if (!event.title().empty()) { |
| query += "&text="; |
| query += base::EscapeQueryParamValue(event.title(), /*use_plus=*/true); |
| } |
| if (!event.description().empty()) { |
| query += "&details="; |
| query += |
| base::EscapeQueryParamValue(event.description(), /*use_plus=*/true); |
| } |
| if (!event.dates().empty()) { |
| query += "&dates="; |
| query += base::EscapeQueryParamValue(event.dates(), /*use_plus=*/true); |
| } |
| if (!event.location().empty()) { |
| query += "&location="; |
| query += base::EscapeQueryParamValue(event.location(), /*use_plus=*/true); |
| } |
| |
| GURL::Replacements replacements; |
| replacements.SetQueryStr(query); |
| return GetCalendarEventTemplateUrl().ReplaceComponents(replacements); |
| } |
| |
| // Given a resource name of a Person from the People API, returns a URL to the |
| // "edit" view of that Person in the Google Contacts web interface. |
| // Returns an invalid GURL if the resource name is invalid. |
| GURL GetEditContactUrl(std::string_view resource_name) { |
| if (!resource_name.starts_with(kPersonResourceNamePrefix)) { |
| // Resource names are guaranteed by the People API documentation to start |
| // with the prefix ("people/"); |
| return GURL(); |
| } |
| resource_name.remove_prefix(kPersonResourceNamePrefix.size()); |
| GURL::Replacements replacements; |
| std::string path = base::StrCat({kPersonContactsWebUiPath, resource_name}); |
| replacements.SetPathStr(path); |
| replacements.SetQueryStr("edit=1"); |
| GURL edit_contact_url = |
| GetGoogleContactsBaseUrl().ReplaceComponents(replacements); |
| |
| if (!edit_contact_url.path_piece().starts_with(kPersonContactsWebUiPath)) { |
| // The resulting URL's path should always start with the given Contacts web |
| // UI path. |
| // This may be indicative of a path traversal attack. |
| return GURL(); |
| } |
| |
| return edit_contact_url; |
| } |
| |
| // Opens the supplied URL in a browser tab using the provided |
| // `ScannerCommandDelegate`. Calls the callback depending on whether the |
| // URL was opened or not (if the delegate was null). |
| // Must be called on the same sequence that called `HandleScannerAction`. |
| void OpenInBrowserTab(base::WeakPtr<ScannerCommandDelegate> delegate, |
| const GURL& gurl, |
| ScannerCommandCallback callback) { |
| if (delegate == nullptr) { |
| std::move(callback).Run(false); |
| return; |
| } |
| delegate->OpenUrl(gurl); |
| std::move(callback).Run(true); |
| } |
| |
| // Writes the given data into a temporary file, then returns the temporary file. |
| // If any I/O fails, this function returns nullopt and cleans up the temporary |
| // file. |
| // Must be run on a thread which can perform I/O. |
| // The returned `ScopedTempFile` must be destructed on a thread which can |
| // perform I/O. |
| // |
| // Care should be taken to ensure that binding this function should take |
| // ownership of the data to prevent UAFs. To be specific, `data` should be bound |
| // with a `std::string` instead of a `std::string_view` so the bound callback |
| // takes ownership of the data given. |
| // |
| // This is because `base::BindOnce`'s internal storage type, |
| // `base::internal::BindState::BoundArgsTuple`, depends on the arguments to |
| // `base::BindOnce`, not the parameters of the function. |
| std::optional<base::ScopedTempFile> CreateTempFileWithContents( |
| std::string_view data) { |
| base::ScopedTempFile temp_file; |
| if (!temp_file.Create()) { |
| return std::nullopt; |
| } |
| |
| base::File file(temp_file.path(), |
| base::File::FLAG_OPEN | base::File::FLAG_WRITE); |
| if (!file.IsValid()) { |
| return std::nullopt; |
| } |
| if (!file.WriteAndCheck(0, base::as_byte_span(data))) { |
| return std::nullopt; |
| } |
| |
| return temp_file; |
| } |
| |
| // Runs after a temporary file has been uploaded to Google Drive. Cleans up the |
| // temporary file and opens the file's alternate link in a browser tab. |
| // Must be called on the same sequence that called `HandleScannerAction`. |
| void OnTempFileUploaded(base::WeakPtr<ScannerCommandDelegate> delegate, |
| ScannerCommandCallback callback, |
| base::ScopedTempFile temp_file, |
| google_apis::ApiErrorCode error, |
| std::unique_ptr<google_apis::FileResource> entry) { |
| // Ensure that `temp_file` is cleaned up in a thread that can perform I/O. |
| absl::Cleanup temp_file_cleanup = [temp_file = |
| std::move(temp_file)]() mutable { |
| base::ThreadPool::PostTask( |
| FROM_HERE, {base::TaskPriority::BEST_EFFORT, base::MayBlock()}, |
| base::DoNothingWithBoundArgs(std::move(temp_file))); |
| }; |
| |
| // `FakeDriveService` returns `HTTP_CREATED` when multipart files are uploaded |
| // successfully. The real API returns `HTTP_SUCCESS`. |
| // Either one indicates a successful upload. |
| if (error != google_apis::ApiErrorCode::HTTP_SUCCESS && |
| error != google_apis::ApiErrorCode::HTTP_CREATED) { |
| std::move(callback).Run(false); |
| return; |
| } |
| if (entry == nullptr) { |
| std::move(callback).Run(false); |
| return; |
| } |
| |
| OpenInBrowserTab(std::move(delegate), entry->alternate_link(), |
| std::move(callback)); |
| } |
| |
| // Uploads a specified temporary file to Drive with the provided MIME types. |
| // `converted_mime_type` must have static lifetime, i.e. must be a compile-time |
| // constant. |
| // Must be called on the same sequence that called `HandleScannerAction`. |
| void UploadTempFileToDrive(base::WeakPtr<ScannerCommandDelegate> delegate, |
| std::string contents_mime_type, |
| std::string_view converted_mime_type, |
| size_t contents_size, |
| std::string title, |
| ScannerCommandCallback callback, |
| std::optional<base::ScopedTempFile> temp_file) { |
| if (!temp_file.has_value()) { |
| std::move(callback).Run(false); |
| return; |
| } |
| |
| if (delegate == nullptr) { |
| std::move(callback).Run(false); |
| return; |
| } |
| drive::DriveServiceInterface* drive_service = delegate->GetDriveService(); |
| if (drive_service == nullptr) { |
| std::move(callback).Run(false); |
| return; |
| } |
| |
| // We need to create a copy of the temp file path, as `MultipartUploadNewFile` |
| // takes in a const ref. Without the copy, the const ref would point to a |
| // *moved* `base::FilePath` as we move `temp_file` into `OnTempFileUploaded`. |
| base::FilePath temp_path = temp_file->path(); |
| drive_service->MultipartUploadNewFile( |
| contents_mime_type, converted_mime_type, contents_size, |
| drive_service->GetRootResourceId(), title, temp_path, |
| drive::UploadNewFileOptions(), |
| base::BindOnce(&OnTempFileUploaded, std::move(delegate), |
| std::move(callback), std::move(*temp_file)), |
| /*progress_callback=*/base::NullCallback()); |
| } |
| |
| void HandleDriveUploadCommand(base::WeakPtr<ScannerCommandDelegate> delegate, |
| DriveUploadCommand command, |
| ScannerCommandCallback callback) { |
| size_t contents_size = command.contents.size(); |
| |
| base::ThreadPool::PostTaskAndReplyWithResult( |
| FROM_HERE, {base::TaskPriority::USER_BLOCKING, base::MayBlock()}, |
| base::BindOnce(&CreateTempFileWithContents, std::move(command.contents)), |
| base::BindOnce(&UploadTempFileToDrive, std::move(delegate), |
| std::move(command.contents_mime_type), |
| std::move(command.converted_mime_type), contents_size, |
| std::move(command.title), std::move(callback))); |
| } |
| |
| std::unique_ptr<ui::ClipboardData> ClipboardDataFromAction( |
| manta::proto::CopyToClipboardAction action) { |
| auto data = std::make_unique<ui::ClipboardData>(); |
| if (!action.plain_text().empty()) { |
| data->set_text(std::move(*action.mutable_plain_text())); |
| } |
| if (!action.html_text().empty()) { |
| data->set_markup_data(std::move(*action.mutable_html_text())); |
| } |
| return data; |
| } |
| |
| // Returns the `google_apis::people::Contact` from the given new contact action. |
| google_apis::people::Contact ContactFromAction( |
| manta::proto::NewContactAction action) { |
| google_apis::people::Contact contact; |
| |
| // `google_apis::people::Name` will not be serialised if all field are empty, |
| // so if the action's name fields are not set, the below would be a no-op. |
| contact.name.family_name = std::move(*action.mutable_family_name()); |
| contact.name.given_name = std::move(*action.mutable_given_name()); |
| |
| if (action.email_addresses_size() > 0) { |
| contact.email_addresses = base::ToVector( |
| *action.mutable_email_addresses(), |
| [](manta::proto::NewContactAction::EmailAddress& proto_email) { |
| google_apis::people::EmailAddress email_address; |
| email_address.value = std::move(*proto_email.mutable_value()); |
| email_address.type = std::move(*proto_email.mutable_type()); |
| return email_address; |
| }); |
| } else if (!action.email().empty()) { |
| google_apis::people::EmailAddress email_address; |
| email_address.value = std::move(*action.mutable_email()); |
| contact.email_addresses.push_back(std::move(email_address)); |
| } |
| |
| if (action.phone_numbers_size() > 0) { |
| contact.phone_numbers = base::ToVector( |
| *action.mutable_phone_numbers(), |
| [](manta::proto::NewContactAction::PhoneNumber& proto_phone) { |
| google_apis::people::PhoneNumber phone_number; |
| phone_number.value = std::move(*proto_phone.mutable_value()); |
| phone_number.type = std::move(*proto_phone.mutable_type()); |
| return phone_number; |
| }); |
| } else if (!action.phone().empty()) { |
| google_apis::people::PhoneNumber phone_number; |
| phone_number.value = std::move(*action.mutable_phone()); |
| contact.phone_numbers.push_back(std::move(phone_number)); |
| } |
| |
| return contact; |
| } |
| |
| // Run when the create contact request to the People API finishes. |
| void OnContactCreated(base::WeakPtr<ScannerCommandDelegate> delegate, |
| ScannerCommandCallback callback, |
| base::expected<google_apis::people::Person, |
| google_apis::ApiErrorCode> result) { |
| if (!result.has_value()) { |
| std::move(callback).Run(false); |
| return; |
| } |
| |
| GURL edit_contact_url = GetEditContactUrl(result->resource_name); |
| if (!edit_contact_url.is_valid()) { |
| std::move(callback).Run(false); |
| return; |
| } |
| |
| OpenInBrowserTab(std::move(delegate), edit_contact_url, std::move(callback)); |
| } |
| |
| } // namespace |
| |
| ScannerCommand ScannerActionToCommand(manta::proto::ScannerAction action) { |
| switch (action.action_case()) { |
| case manta::proto::ScannerAction::kNewEvent: |
| return OpenUrlCommand( |
| GetCalendarEventUrl(std::move(*action.mutable_new_event()))); |
| |
| case manta::proto::ScannerAction::kNewContact: |
| return CreateContactCommand( |
| ContactFromAction(std::move(*action.mutable_new_contact()))); |
| |
| case manta::proto::ScannerAction::kNewGoogleDoc: |
| return DriveUploadCommand( |
| std::move(*action.mutable_new_google_doc()->mutable_title()), |
| std::move(*action.mutable_new_google_doc()->mutable_html_contents()), |
| /*contents_mime_type=*/"text/html", |
| /*converted_mime_type=*/drive::util::kGoogleDocumentMimeType); |
| |
| case manta::proto::ScannerAction::kNewGoogleSheet: |
| return DriveUploadCommand( |
| std::move(*action.mutable_new_google_sheet()->mutable_title()), |
| std::move(*action.mutable_new_google_sheet()->mutable_csv_contents()), |
| /*contents_mime_type=*/"text/csv", |
| /*converted_mime_type=*/ |
| drive::util::kGoogleSpreadsheetMimeType); |
| |
| case manta::proto::ScannerAction::kCopyToClipboard: |
| return CopyToClipboardCommand(ClipboardDataFromAction( |
| std::move(*action.mutable_copy_to_clipboard()))); |
| |
| case manta::proto::ScannerAction::ACTION_NOT_SET: |
| NOTREACHED(); |
| } |
| } |
| |
| void HandleScannerCommand(base::WeakPtr<ScannerCommandDelegate> delegate, |
| ScannerCommand command, |
| ScannerCommandCallback callback) { |
| if (delegate == nullptr) { |
| std::move(callback).Run(false); |
| return; |
| } |
| |
| std::visit( |
| absl::Overload{ |
| [&](OpenUrlCommand& command) { |
| OpenInBrowserTab(std::move(delegate), command.url, |
| std::move(callback)); |
| }, |
| [&](DriveUploadCommand& command) { |
| HandleDriveUploadCommand(std::move(delegate), std::move(command), |
| std::move(callback)); |
| }, |
| [&](CopyToClipboardCommand& command) { |
| delegate->SetClipboard(std::move(command.clipboard_data)); |
| std::move(callback).Run(true); |
| }, |
| [&](CreateContactCommand& command) { |
| google_apis::RequestSender* request_sender = |
| delegate->GetGoogleApisRequestSender(); |
| |
| if (request_sender == nullptr) { |
| std::move(callback).Run(false); |
| return; |
| } |
| |
| request_sender->StartRequestWithAuthRetry( |
| std::make_unique<google_apis::people::CreateContactRequest>( |
| request_sender, std::move(command.contact), |
| base::BindOnce(&OnContactCreated, std::move(delegate), |
| std::move(callback)))); |
| }, |
| }, |
| command); |
| } |
| |
| } // namespace ash |