defmodule Glific do import Ecto.Changeset @moduledoc """ Glific keeps the contexts that define your domain and business logic. Contexts are also responsible for managing your data, regardless if it comes from the database, an external API or others. For now we'll keep some commonly used functions here, until we need a new file """ @captcha_verify_url "https://www.google.com/recaptcha/api/siteverify" @captcha_score_threshold 0.5 require Logger alias Glific.{ Partners, Repo } alias Tesla.Multipart @doc """ Wrapper to return :ok/:error when parsing strings to potential integers """ @spec parse_maybe_integer(String.t() | integer) :: {:ok, integer} | {:ok, nil} | :error def parse_maybe_integer(value) when is_integer(value), do: {:ok, value} def parse_maybe_integer(nil), do: {:ok, nil} def parse_maybe_integer(value) do case Integer.parse(value) do {n, ""} -> {:ok, n} {_num, _rest} -> :error :error -> :error end end @doc """ parse and integer and die if parse fails """ @spec parse_maybe_integer!(String.t() | integer) :: integer def parse_maybe_integer!(value) do {:ok, value} = parse_maybe_integer(value) value end @doc """ Wrapper to return :ok/:error when parsing strings to potential integers """ @spec parse_maybe_number(String.t() | integer) :: {:ok, integer} | {:ok, nil} | :error def parse_maybe_number(nil), do: {:ok, nil} def parse_maybe_number(value) when is_integer(value), do: {:ok, value} def parse_maybe_number(value) when is_float(value), do: {:ok, value} def parse_maybe_number(value) do case Integer.parse(value) do :error -> :error {n, ""} -> {:ok, n} _ -> Float.parse(value) |> case do {n, ""} -> {:ok, n} _ -> :error end end end @doc """ Validates inputted shortcode, if shortcode is invalid it returns message that the shortcode is invalid along with the valid shortcode. """ @spec( validate_shortcode(Ecto.Changeset.t()) :: Ecto.Changeset.t() | Ecto.Changeset.t(), atom(), String.t() ) def validate_shortcode(%Ecto.Changeset{} = changeset) do shortcode = Map.get(changeset.changes, :shortcode) valid_shortcode = string_clean(shortcode) if valid_shortcode == shortcode, do: changeset, else: add_error( changeset, :shortcode, "Invalid shortcode, valid shortcode will be #{valid_shortcode}" ) end @doc """ Lets get rid of all non valid characters. We are assuming any language and hence using unicode syntax and not restricting ourselves to alphanumeric """ @spec string_clean(String.t() | nil) :: String.t() | nil def string_clean(str) when is_nil(str) or str == "", do: str def string_clean(str), do: str |> String.replace(~r/[\p{P}\p{S}\p{Z}\p{C}]+/u, "") |> String.downcase() |> String.trim() @doc """ convert string to snake case """ @spec string_snake_case(String.t() | nil) :: String.t() | nil def string_snake_case(str) when is_nil(str) or str == "", do: str def string_snake_case(str), do: str |> String.replace(~r/\s+/, "_") |> String.downcase() @doc """ See if the current time is within the past time units """ @spec in_past_time(DateTime.t(), atom(), integer) :: boolean def in_past_time(time, units \\ :hours, back \\ 24), do: Timex.diff(DateTime.utc_now(), time, units) < back @doc """ Return a time object where you go back x units. We introduce the notion of hour and minute """ @spec go_back_time(integer, DateTime.t(), atom()) :: DateTime.t() def go_back_time(go_back, time \\ DateTime.utc_now(), unit \\ :hour) do # convert hours to second {unit, go_back} = case unit do :hour -> {:second, go_back * 60 * 60} :minute -> {:second, go_back * 60} _ -> {unit, go_back} end DateTime.add(time, -1 * go_back, unit) end @doc """ Convert map string keys to :atom keys """ @spec atomize_keys(any) :: any def atomize_keys(nil), do: nil # Structs don't do enumerable and anyway the keys are already # atoms def atomize_keys(map) when is_struct(map), do: map def atomize_keys([head | rest] = list) when is_list(list), do: [atomize_keys(head) | atomize_keys(rest)] def atomize_keys(map) when is_map(map), do: Enum.map(map, fn {k, v} -> if is_atom(k) do {k, atomize_keys(v)} else {Glific.safe_string_to_atom(k), atomize_keys(v)} end end) |> Enum.into(%{}) def atomize_keys(value), do: value @doc """ easy way for glific developers to get a stacktrace when debugging """ @spec stacktrace :: String.t() def stacktrace do {_, stacktrace} = Process.info(self(), :current_stacktrace) inspect(stacktrace) end @not_allowed ["Repo.", "IO.", "File.", "Code."] @doc """ Really simple function to ensure folks do not add Repo and/or IO calls to an EEx snippet. This is an extremely short term fix to avoid shooting ourselves in the foot, but we should move to lua for flows scripting in the near future Note that folks can potentially find other ways to access the same modules, so this by no means should be considered remotely secure """ @spec suspicious_code(String.t()) :: boolean() def suspicious_code(code), do: String.contains?(code, @not_allowed) @doc """ execute string in eex """ @spec execute_eex(String.t()) :: String.t() def execute_eex(content) do if suspicious_code(content) do Logger.error("EEx suspicious code: #{content}") "Suspicious Code. Please change your code. #{content}" else content |> EEx.eval_string() |> String.trim() end rescue EEx.SyntaxError -> Logger.error("EEx threw a SyntaxError: #{content}") "Invalid Code" _ -> Logger.error("EEx threw a Error: #{content}") "Invalid Code" end @doc """ Compute the signature at a specific time for the body """ @spec signature(non_neg_integer, String.t(), non_neg_integer) :: String.t() def signature(organization_id, body, timestamp) do secret = Partners.organization(organization_id).signature_phrase signed_payload = "#{timestamp}.#{body}" hmac = :crypto.mac(:hmac, :sha256, secret, signed_payload) Base.encode16(hmac, case: :lower) end @doc """ You shouldn’t really use String.to_atom/1 on user-supplied data. The BEAM has a limit on how many different atoms you can have and they’re not garbage collected! With data coming from outside the system, stick to strings or use String.to_existing_atom/1 instead! So this is a generic function which will convert the string to atom and throws an error in case of invalid key """ @spec safe_string_to_atom(String.t() | atom(), atom()) :: atom() def safe_string_to_atom(value, default \\ :invalid_atom) def safe_string_to_atom(value, _default) when is_atom(value), do: value def safe_string_to_atom(value, default) do String.to_existing_atom(value) rescue ArgumentError -> error = "#{value} can not be converted to atom" Appsignal.send_error(:error, error, __STACKTRACE__) default end @doc """ Delete multiple items from the map """ @spec delete_multiple(map(), list()) :: map() def delete_multiple(map, list) do list |> Enum.reduce( map, fn l, acc -> Map.delete(acc, l) end ) end @doc """ Given a string separated by spaces, commas, or semi-colons, create a set of individual elements in the string """ @spec make_set(String.t(), list()) :: MapSet.t() def make_set(str, separators \\ [",", ";"]) do str # string downcase for making it case-insensitive |> String.downcase() # First ALWAYS split by white space |> String.split() # then split by separators |> Enum.flat_map(fn x -> String.split(x, separators, trim: true) end) # finally create a mapset for easy fast checks |> MapSet.new() end @doc """ Intermediary function to update the input params with organization id as operation is performed by glific_admin for other organizations """ @spec substitute_organization_id(map(), any, atom()) :: map() def substitute_organization_id(params, value, key) when is_integer(value), do: substitute_organization_id(params, "#{value}", key) def substitute_organization_id(params, value, key) do value |> String.to_integer() |> Repo.put_process_state() params |> Map.put(:organization_id, value) |> Map.delete(key) end @doc """ A hack to suppress error messages when running lots of flows. These are expected and we want to improve signal <-> noise ratio """ @spec ignore_error?(String.t()) :: boolean def ignore_error?(error) do # These errors are ok, and need not be reported to appsignal # to a large extent, its more a completion exit rather than an # error exit String.contains?(error, "Exit Loop") || String.contains?(error, "finished the flow") end @doc """ Log the error and also send it over to our friends at appsignal """ @spec log_error(String.t(), boolean) :: {:error, String.t()} def log_error(error, send_appsignal? \\ true) do Logger.error(error) # disable sending exit loop and finished flow errors, since # these are beneficiary errors if !ignore_error?(error) && send_appsignal? do {_, stacktrace} = Process.info(self(), :current_stacktrace) Appsignal.send_error(:error, error, stacktrace) end {:error, error} end @doc """ Verifying Google Captcha """ @spec verify_google_captcha(String.t()) :: {:ok, String.t()} | {:error, any()} def verify_google_captcha(token) do create_request(token) |> then(&Tesla.post(@captcha_verify_url, &1)) |> handle_response() end @spec create_request(String.t()) :: Tesla.Multipart.t() defp create_request(token) do Multipart.new() |> Multipart.add_field("secret", Application.get_env(:glific, :google_captcha_secret_key)) |> Multipart.add_field("response", token) end @spec handle_response(tuple()) :: tuple() defp handle_response(response) do response |> case do {:ok, %Tesla.Env{status: 200, body: body}} -> response_body = Jason.decode!(body) if response_body["success"] && response_body["score"] > @captcha_score_threshold do {:ok, "success"} else captcha_error = response_body |> Map.get("error-codes", "Token verification failed") |> List.first() Logger.info("Failed to verify Google Captcha: #{captcha_error}") {:error, "Failed to verify Google Captcha: #{captcha_error}"} end {_status, response} -> Logger.info("Invalid response verifying Google Captcha: #{response}") {:error, "invalid response #{inspect(response)}"} end end @doc """ Adds a limit to restrict accessing data from big tables like messages, contacts which slows DB and takes longer to complete request Adding upper limit to 50 when limit is passed and is more than 50 Adding limit to 25 when limit is not passed in args """ @spec add_limit(map) :: map() def add_limit(%{opts: %{limit: limit}} = args) when limit > 50 do opts = Map.get(args, :opts, %{}) Map.put(args, :opts, Map.put(opts, :limit, 50)) end def add_limit(%{opts: %{limit: _limit}} = args), do: args def add_limit(%{opts: opts} = args), do: Map.put(args, :opts, Map.put(opts, :limit, 25)) def add_limit(args), do: Map.put(args, :opts, Map.put(%{}, :limit, 25)) end