lib/cldr/install.ex

defmodule Cldr.Install do
  @moduledoc """
  Provides functions for installing locales.

  When installed as a package on from [hex](http://hex.pm), `Cldr` has only
  the default locales `["en", "und"]` installed and configured.

  When other locales are added to the configuration `Cldr` will attempt to
  download the locale from [github](https://github.com/kipcole9/cldr)
  during compilation.

  If `Cldr` is installed from github directly then all locales are already
  installed.

  """

  defdelegate client_data_dir(config), to: Cldr.Config
  defdelegate client_locales_dir(config), to: Cldr.Config
  defdelegate locale_filename(locale), to: Cldr.Config

  @doc """
  Install all the configured locales.

  """
  def install_known_locale_names(config) do
    config
    |> Cldr.Locale.Loader.known_locale_names()
    |> Enum.each(&install_locale_name(&1, config))

    :ok
  end

  @doc """
  Install all available locales.
  """
  def install_all_locale_names(config) do
    Cldr.Config.all_locale_names()
    |> Enum.each(&install_locale_name(&1, config))

    :ok
  end

  @doc """
  Download the requested locale from github into the
  client application's cldr data directory.

  * `locale` is any locale returned by `Cldr.known_locale_names/1`

  * `options` is a keyword list.  Currently the only supported
    option is `:force` which defaults to `false`.  If `truthy` the
    locale will be installed or re-installed.

  The data directory is typically `priv/cldr/locales`.

  This function is intended to be invoked during application
  compilation when a valid locale is configured but is not yet
  installed in the application.

  An https request to the master github repository for `Cldr` is made
  to download the correct version of the locale file which is then
  written to the configured data directory.

  """
  def install_locale_name(locale_name, config, options \\ []) do
    force_download? = config.force_locale_download || options[:force]

    if !locale_installed?(locale_name, config) || force_download? do
      ensure_client_dirs_exist!(client_locales_dir(config))
      Application.ensure_started(:inets)
      Application.ensure_started(:ssl)
      Application.ensure_started(Cldr.Config.app_name())
      do_install_locale_name(locale_name, config, locale_name in Cldr.Config.all_locale_names())
    else
      output_file_name = locale_output_file_name(locale_name, config)
      Cldr.maybe_log("Locale already installed and found at #{inspect(output_file_name)}")
      :already_installed
    end
  end

  # Normally a library function shouldn't raise an exception (that's up
  # to the client app) but we install locales only at compilation time
  # and an exception then is the appropriate response.
  defp do_install_locale_name(locale_name, _config, false) do
    raise Cldr.UnknownLocaleError,
          "Failed to install the locale named #{inspect(locale_name)}. The locale name is not known."
  end

  defp do_install_locale_name(locale_name, config, true) do
    require Logger

    output_file_name = locale_output_file_name(locale_name, config)
    url = String.to_charlist("#{base_url()}#{locale_filename(locale_name)}")

    case :httpc.request(:get, {url, headers()}, https_opts(), []) do
      {:ok, {{_version, 200, 'OK'}, _headers, body}} ->
        output_file_name
        |> File.write!(body)

        Logger.bare_log(:info, "Downloaded locale #{inspect(locale_name)}")
        {:ok, output_file_name}

      {_, {{_version, code, message}, _headers, _body}} ->
        Logger.bare_log(
          :error,
          "Failed to download locale #{inspect(locale_name)} from #{url}. " <>
            "HTTP Error: (#{code}) #{inspect(message)}"
        )

        {:error, code}

      {:error, {:failed_connect, [{_, {host, _port}}, {_, _, sys_message}]}} ->
        Logger.bare_log(
          :error,
          "Failed to connect to #{inspect(host)} to download " <>
            "locale #{inspect(locale_name)}. Reason: #{inspect(sys_message)}"
        )

        {:error, sys_message}
    end
  end

  defp locale_output_file_name(locale_name, config) do
    [client_locales_dir(config), "/", locale_filename(locale_name)]
    |> :erlang.iolist_to_binary()
  end

  defp headers do
    # [{'Connection', 'close'}]
    []
  end

  @certificate_locations [
                           # Configured cacertfile
                           Application.get_env(Cldr.Config.app_name(), :cacertfile),

                           # Populated if hex package CAStore is configured
                           if(Code.ensure_loaded?(CAStore), do: CAStore.file_path()),

                           # Populated if hex package certfi is configured
                           if(Code.ensure_loaded?(:certifi),
                             do: :certifi.cacertfile() |> List.to_string()
                           ),

                           # Debian/Ubuntu/Gentoo etc.
                           "/etc/ssl/certs/ca-certificates.crt",

                           # Fedora/RHEL 6
                           "/etc/pki/tls/certs/ca-bundle.crt",

                           # OpenSUSE
                           "/etc/ssl/ca-bundle.pem",

                           # OpenELEC
                           "/etc/pki/tls/cacert.pem",

                           # CentOS/RHEL 7
                           "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",

                           # Open SSL on MacOS
                           "/usr/local/etc/openssl/cert.pem",

                           # MacOS & Alpine Linux
                           "/etc/ssl/cert.pem"
                         ]
                         |> Enum.reject(&is_nil/1)

  def certificate_store do
    @certificate_locations
    |> Enum.find(&File.exists?/1)
    |> raise_if_no_cacertfile
    |> :erlang.binary_to_list()
  end

  defp raise_if_no_cacertfile(nil) do
    raise RuntimeError, """
    No certificate trust store was found.
    Tried looking for: #{inspect(@certificate_locations)}

    A certificate trust store is required in
    order to download locales for your configuration.

    Since ex_cldr could not detect a system
    installed certificate trust store one of the
    following actions may be taken:

    1. Install the hex package `castore`. It will
       be automatically detected after recompilation.

    2. Install the hex package `certifi`. It will
       be automatically detected after recomilation.

    3. Specify the location of a certificate trust store
       by configuring it in `config.exs`:

       config :ex_cldr,
         cacertfile: "/path/to/cacertfile",
         ...

    """
  end

  defp raise_if_no_cacertfile(file) do
    file
  end

  defp https_opts do
    [
      ssl: [
        verify: :verify_peer,
        cacertfile: certificate_store(),
        customize_hostname_check: [
          match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
        ]
      ]
    ]
  end

  # Builds the base url to retrieve a locale file from github.
  #
  # The url is built using the version number of the `Cldr` application.
  # If the version is a `-dev` version then the locale file is downloaded
  # from the master branch.
  #
  # This requires that a branch is tagged with the version number before creating
  # a release or publishing to hex.

  @base_url "https://raw.githubusercontent.com/elixir-cldr/cldr/"
  defp base_url do
    [@base_url, branch_from_version(), "/priv/cldr/locales/"]
    |> :erlang.iolist_to_binary()
  end

  # Returns the version of ex_cldr
  defp app_version do
    cond do
      spec = Application.spec(Cldr.Config.app_name()) ->
        Keyword.get(spec, :vsn) |> :erlang.list_to_binary()

      Code.ensure_loaded?(Cldr.Mixfile) ->
        module = Module.concat(Cldr, Mixfile)
        Keyword.get(module.project(), :version)

      true ->
        :error
    end
  end

  # Get the git branch name based upon the app version
  defp branch_from_version do
    version = app_version()

    if String.contains?(version, "-dev") do
      "master"
    else
      "v#{version}"
    end
  end

  @doc """
  Returns a `boolean` indicating if the requested locale is installed.

  No checking of the validity of the `locale` itself is performed.  The
  check is based upon whether there is a locale file installed in the
  client application or in `Cldr` itself.
  """
  def locale_installed?(locale, config) do
    case Cldr.Config.locale_path(locale, config) do
      {:ok, _path} -> true
      _ -> false
    end
  end

  @doc """
  Returns the full pathname of the locale's json file.

  * `locale` is any locale returned by `Cldr.known_locale_names/1`

  No checking of locale validity is performed.
  """
  def client_locale_file(locale, config) do
    Path.join(client_locales_dir(config), "#{locale}.json")
  end

  # Create the client app locales directory and any directories
  # that don't exist above it.
  defp ensure_client_dirs_exist!(dir) do
    File.mkdir_p(dir)
  end
end