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