chromium/ash/ambient/managed/screensaver_image_downloader.cc

// Copyright 2023 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/ambient/managed/screensaver_image_downloader.h"

#include <string>

#include "ash/ambient/metrics/managed_screensaver_metrics.h"
#include "base/containers/flat_set.h"
#include "base/files/file_enumerator.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/hash/sha1.h"
#include "base/logging.h"
#include "base/ranges/algorithm.h"
#include "base/strings/string_number_conversions.h"
#include "base/task/thread_pool.h"
#include "base/values.h"
#include "net/http/http_request_headers.h"
#include "services/network/public/cpp/resource_request.h"
#include "services/network/public/cpp/shared_url_loader_factory.h"
#include "services/network/public/mojom/url_response_head.mojom.h"

namespace ash {

namespace {

constexpr net::NetworkTrafficAnnotationTag
    kScreensaverImageDownloaderNetworkTag =
        net::DefineNetworkTrafficAnnotation("screensaver_image_downloader",
                                            R"(
        semantics {
          sender: "Managed Screensaver"
          description:
            "Fetch external image files that will be cached and displayed "
            "in the policy-controlled screensaver."
          trigger:
            "An update to the ScreensaverLockScreenImages policy that includes "
            "new references to external image files."
          data:
            "This request does not send any data from the device. It fetches"
            "images from URLs provided by the policy."
          destination: OTHER
          user_data {
            type: NONE
          }
          internal {
            contacts {
              email: "[email protected]"
            }
          }
          last_reviewed: "2023-03-30"
        }
        policy {
          cookies_allowed: NO
          setting:
            "This feature is controlled by enterprise policies, and cannot"
            "be overridden by users. It is disabled by default."
          chrome_policy {
            ScreensaverLockScreenImages {
              ScreensaverLockScreenImages {
                  entries: ""
              }
            }
          }
        })");
constexpr char kCacheFileExt[] = ".cache";
constexpr char kCacheFileWildCardPattern[] = "*.cache";

constexpr int64_t kMaxFileSizeInBytes = 8 * 1024 * 1024;  // 8 MB
constexpr int kMaxUrlFetchRetries = 3;

// This limit is specified in the policy definition for the policies
// ScreensaverLockScreenImages and DeviceScreensaverLoginScreenImages.
constexpr size_t kMaxUrlsToProcessFromPolicy = 25u;

std::unique_ptr<network::SimpleURLLoader> CreateSimpleURLLoader(
    const std::string& url) {
  auto request = std::make_unique<network::ResourceRequest>();
  request->url = GURL(url);
  request->method = net::HttpRequestHeaders::kGetMethod;
  request->credentials_mode = network::mojom::CredentialsMode::kOmit;
  CHECK(request->url.SchemeIs(url::kHttpsScheme));

  auto loader = network::SimpleURLLoader::Create(
      std::move(request), kScreensaverImageDownloaderNetworkTag);
  const int retry_mode = network::SimpleURLLoader::RETRY_ON_5XX |
                         network::SimpleURLLoader::RETRY_ON_NETWORK_CHANGE;
  loader->SetRetryOptions(kMaxUrlFetchRetries, retry_mode);
  return loader;
}

// Helper function to extract response code from `SimpleURLLoader`.
int GetResponseCode(network::SimpleURLLoader* simple_loader) {
  if (!simple_loader->ResponseInfo() ||
      !simple_loader->ResponseInfo()->headers) {
    return -1;
  }
  return simple_loader->ResponseInfo()->headers->response_code();
}

bool VerifyOrCreateDownloadDirectory(const base::FilePath& download_directory) {
  if (!base::DirectoryExists(download_directory) &&
      !base::CreateDirectory(download_directory)) {
    LOG(ERROR) << "Cannot create download directory";
    // TODO(b/276208772): Track result with metrics
    return false;
  }
  if (!base::PathIsWritable(download_directory)) {
    LOG(ERROR) << "Cannot write to download directory";
    // TODO(b/276208772): Track result with metrics
    return false;
  }
  return true;
}

std::string GetHashedFileNameForUrl(const std::string& url) {
  auto hash = base::SHA1Hash(base::as_byte_span(url));
  return base::HexEncode(hash) + kCacheFileExt;
}

std::vector<std::string> GetImageUrlsToProcess(
    const base::Value::List& image_url_list) {
  std::vector<std::string> urls;
  for (size_t i = 0;
       i < kMaxUrlsToProcessFromPolicy && i < image_url_list.size(); ++i) {
    const base::Value& value = image_url_list[i];
    if (!value.is_string() || value.GetString().empty()) {
      continue;
    }
    // Canonicalize URLs and require HTTPS.
    GURL url(value.GetString());
    if (!url.is_valid() || !url.SchemeIs(url::kHttpsScheme)) {
      LOG(WARNING) << "Ignored invalid URL: " << url;
      continue;
    }

    urls.emplace_back(url.spec());
  }
  return urls;
}

// Returns all the cached images in the provided directory.
// This method does blocking IO and should only be run on a thread that
// allows blocking IO.
std::vector<base::FilePath> GetCachedImagesFromDisk(
    const base::FilePath& directory) {
  std::vector<base::FilePath> images_on_disk;
  base::FileEnumerator iterator(directory, /*recursive=*/false,
                                base::FileEnumerator::FILES,
                                FILE_PATH_LITERAL(kCacheFileWildCardPattern));
  base::FilePath current_path;
  for (base::FilePath path = iterator.Next(); !path.empty();
       path = iterator.Next()) {
    images_on_disk.push_back(path);
  }

  return images_on_disk;
}

// Deletes all the provided files in the `files_to_delete` parameter.
// This method does blocking IO and should only be run on a thread that
// allows blocking IO.
// Note: In case all of the files are successfully deleted, this will return
// true otherwise will return false.
bool DeleteFiles(const std::vector<base::FilePath>& files_to_delete) {
  bool success = true;
  for (const auto& path : files_to_delete) {
    // Even if one file fails to delete mark this operation as not being
    // success.
    if (!base::DeleteFile(path)) {
      LOG(WARNING) << "Failed to clean up: " << path.BaseName().value();
      success = false;
    }
  }
  return success;
}

}  // namespace

ScreensaverImageDownloader::ScreensaverImageDownloader(
    scoped_refptr<network::SharedURLLoaderFactory> shared_url_loader_factory,
    const base::FilePath& download_directory,
    ImageListUpdatedCallback image_list_updated_callback)
    : task_runner_(base::ThreadPool::CreateSequencedTaskRunner(
          {base::MayBlock(), base::TaskPriority::BEST_EFFORT,
           base::TaskShutdownBehavior::CONTINUE_ON_SHUTDOWN})),
      shared_url_loader_factory_(shared_url_loader_factory),
      download_directory_(download_directory),
      image_list_updated_callback_(image_list_updated_callback) {}

ScreensaverImageDownloader::~ScreensaverImageDownloader() = default;

void ScreensaverImageDownloader::UpdateImageUrlList(
    const base::Value::List& image_url_list) {
  if (image_url_list.empty()) {
    // If the screensaver is listening to updates, notify that the images are no
    // longer available before deleting them.
    image_list_updated_callback_.Run(std::vector<base::FilePath>());

    ClearRequestQueue();
    weak_ptr_factory_.InvalidateWeakPtrs();
    DeleteDownloadedImages();
    downloaded_images_.clear();
    return;
  }

  const std::vector<std::string> new_image_urls =
      GetImageUrlsToProcess(image_url_list);

  // `this` is unretained here as `PostTaskAndReplyWithResult` does not work
  // with weak ptrs as weak ptrs do not work with functions that return a value.
  // The usage is safe as the task is executed immediately and `this` should
  // outlive the call.
  task_runner_->PostTaskAndReplyWithResult(
      FROM_HERE,
      base::BindOnce(&ScreensaverImageDownloader::DeleteUnreferencedImageFiles,
                     base::Unretained(this), new_image_urls),
      base::BindOnce(&ScreensaverImageDownloader::OnUnreferencedImagesDeleted,
                     weak_ptr_factory_.GetWeakPtr()));

  for (const std::string& image_url : new_image_urls) {
    DVLOG(1) << "Queue URL: " << image_url;
    QueueImageDownload(image_url);
  }
}

std::vector<base::FilePath>
ScreensaverImageDownloader::DeleteUnreferencedImageFiles(
    const std::vector<std::string>& new_image_urls) {
  std::vector<std::string> hashed_image_urls = new_image_urls;
  // Hash the image url
  base::ranges::transform(hashed_image_urls.begin(), hashed_image_urls.end(),
                          hashed_image_urls.begin(), GetHashedFileNameForUrl);

  base::flat_set<std::string> hashed_image_file_paths(hashed_image_urls);

  auto cached_images_from_disk = GetCachedImagesFromDisk(download_directory_);

  std::vector<base::FilePath> file_paths_to_delete;
  for (const auto& downloaded_file : cached_images_from_disk) {
    if (!hashed_image_file_paths.contains(downloaded_file.BaseName().value())) {
      file_paths_to_delete.push_back(downloaded_file);
    }
  }
  if (!DeleteFiles(file_paths_to_delete)) {
    // TODO(b/276208772): Track result with metrics
    DLOG(WARNING) << "Failed to delete some of the files";
  }

  return file_paths_to_delete;
}

void ScreensaverImageDownloader::OnUnreferencedImagesDeleted(
    std::vector<base::FilePath> file_paths_deleted) {
  if (file_paths_deleted.empty()) {
    return;
  }
  for (const auto& path : file_paths_deleted) {
    downloaded_images_.erase(path);
    DVLOG(1) << "Removing path from in memory cache " << path;
  }

  image_list_updated_callback_.Run(std::vector<base::FilePath>(
      downloaded_images_.begin(), downloaded_images_.end()));
}

std::vector<base::FilePath> ScreensaverImageDownloader::GetScreensaverImages() {
  return std::vector<base::FilePath>(downloaded_images_.begin(),
                                     downloaded_images_.end());
}

void ScreensaverImageDownloader::SetImagesForTesting(
    const std::vector<base::FilePath>& images_file_paths) {
  downloaded_images_ = base::flat_set<base::FilePath>(images_file_paths);
}

base::FilePath ScreensaverImageDownloader::GetDowloadDirForTesting() {
  return download_directory_;
}

void ScreensaverImageDownloader::QueueImageDownload(
    const std::string& image_url) {
  // TODO(b/276208772): Track queue usage with metrics
  if (queue_state_ == QueueState::kWaiting) {
    CHECK(downloading_queue_.empty());
    StartImageDownload(image_url);
  } else {
    downloading_queue_.emplace(image_url);
  }
}

void ScreensaverImageDownloader::ClearRequestQueue() {
  base::queue<std::string> buffer_queue;
  buffer_queue.swap(downloading_queue_);
  queue_state_ = QueueState::kWaiting;

  while (!buffer_queue.empty()) {
    FinishImageDownload(buffer_queue.front(),
                        ScreensaverImageDownloadResult::kCancelled,
                        std::nullopt);
    buffer_queue.pop();
  }
}

void ScreensaverImageDownloader::DeleteDownloadedImages() {
  // TODO(b/278548884): Do not ignore callback result and track its result.
  task_runner_->PostTask(
      FROM_HERE,
      base::BindOnce(base::IgnoreResult(&base::DeletePathRecursively),
                     download_directory_));
}

void ScreensaverImageDownloader::StartImageDownload(
    const std::string& image_url) {
  queue_state_ = QueueState::kDownloading;
  task_runner_->PostTaskAndReplyWithResult(
      FROM_HERE,
      base::BindOnce(&VerifyOrCreateDownloadDirectory, download_directory_),
      base::BindOnce(
          &ScreensaverImageDownloader::OnVerifyDownloadDirectoryCompleted,
          weak_ptr_factory_.GetWeakPtr(), image_url));
}

void ScreensaverImageDownloader::OnVerifyDownloadDirectoryCompleted(
    const std::string& image_url,
    bool can_download_file) {
  if (!can_download_file) {
    FinishImageDownload(image_url,
                        ScreensaverImageDownloadResult::kFileSystemWriteError,
                        std::nullopt);
    return;
  }

  // The download folder exists, check if the file is already in cache before
  // attempting to download it.
  const base::FilePath file_path =
      download_directory_.AppendASCII(GetHashedFileNameForUrl(image_url));
  task_runner_->PostTaskAndReplyWithResult(
      FROM_HERE, base::BindOnce(&base::PathExists, file_path),
      base::BindOnce(&ScreensaverImageDownloader::OnCheckIsFileIsInCache,
                     weak_ptr_factory_.GetWeakPtr(), file_path, image_url));
}

void ScreensaverImageDownloader::OnCheckIsFileIsInCache(
    const base::FilePath& file_path,
    const std::string& image_url,
    bool is_file_present) {
  if (is_file_present) {
    FinishImageDownload(image_url, ScreensaverImageDownloadResult::kSuccess,
                        file_path);
    return;
  }

  CHECK(shared_url_loader_factory_);
  std::unique_ptr<network::SimpleURLLoader> simple_loader =
      CreateSimpleURLLoader(image_url);

  auto* loader = simple_loader.get();
  // Download to temp file first to guarantee entire image is written without
  // errors before attempting to read it.
  loader->DownloadToTempFile(
      shared_url_loader_factory_.get(),
      base::BindOnce(&ScreensaverImageDownloader::OnUrlDownloadedToTempFile,
                     weak_ptr_factory_.GetWeakPtr(), std::move(simple_loader),
                     image_url),
      kMaxFileSizeInBytes);
}

void ScreensaverImageDownloader::OnUrlDownloadedToTempFile(
    std::unique_ptr<network::SimpleURLLoader> simple_loader,
    const std::string& image_url,
    base::FilePath temp_path) {
  const base::FilePath desired_path =
      download_directory_.AppendASCII(GetHashedFileNameForUrl(image_url));
  if (simple_loader->NetError() != net::OK || temp_path.empty()) {
    LOG(ERROR) << "Downloading to file failed with error code: "
               << GetResponseCode(simple_loader.get()) << " with network error "
               << simple_loader->NetError();

    if (!temp_path.empty()) {
      // Clean up temporary file.
      task_runner_->PostTask(
          FROM_HERE,
          base::BindOnce(base::IgnoreResult(&base::DeleteFile), temp_path));
    }
    FinishImageDownload(
        image_url, ScreensaverImageDownloadResult::kNetworkError, std::nullopt);
    return;
  }

  // Swap the temporary file to the desired path, and then run the callback.
  task_runner_->PostTaskAndReplyWithResult(
      FROM_HERE, base::BindOnce(&base::Move, temp_path, desired_path),
      base::BindOnce(&ScreensaverImageDownloader::OnUrlDownloadToFileComplete,
                     weak_ptr_factory_.GetWeakPtr(), desired_path, image_url));
}

void ScreensaverImageDownloader::OnUrlDownloadToFileComplete(
    const base::FilePath& path,
    const std::string& image_url,
    bool file_is_present) {
  if (!file_is_present) {
    DLOG(WARNING) << "Could not save the downloaded file to " << path;
    FinishImageDownload(image_url,
                        ScreensaverImageDownloadResult::kFileSaveError,
                        std::nullopt);
    return;
  }

  FinishImageDownload(image_url, ScreensaverImageDownloadResult::kSuccess,
                      path);
}

void ScreensaverImageDownloader::FinishImageDownload(
    const std::string& image_url,
    ScreensaverImageDownloadResult result,
    std::optional<base::FilePath> path) {
  RecordManagedScreensaverImageDownloadResult(result);

  if (result == ScreensaverImageDownloadResult::kSuccess) {
    downloaded_images_.insert(*path);
    image_list_updated_callback_.Run(std::vector<base::FilePath>(
        downloaded_images_.begin(), downloaded_images_.end()));
  }

  if (downloading_queue_.empty()) {
    queue_state_ = QueueState::kWaiting;
  } else {
    StartImageDownload(std::move(downloading_queue_.front()));
    downloading_queue_.pop();
  }
}

}  // namespace ash