defmodule EctoCommons.URLValidator do @moduledoc ~S""" This validator is used to validate URLs. ## Options There are some available `:checks` depending on the strictness of what you want to validate: - `:parsable`: Checks to see if the URL is parsable by `:http_uri.parse/1` Erlang function. This can have issues with international URLs where it should be disabled (see tests). Defaults to enabled - `:empty`: Checks to see if the parsed `%URI{}` struct is not empty (all fields set to nil). Defaults to enabled - `:scheme`: Checks to see if the parsed `%URI{}` struct contains a `:scheme`. Defaults to enabled - `:host`: Checks to see if the parsed `%URI{}` struct contains a `:host`. Defaults to enabled - `:valid_host`: Does a `:inet.getbyhostname/1` call to check if the host exists. This will do a network call. Defaults to disabled - `:path`: Checks to see if the parsed `%URI{}` struct contains a `:path`. Defaults to disabled - `:http_regexp`: Tries to match URL to a regexp known to catch many unwanted URLs (see code). It only accepts HTTP(S) and FTP schemes though. Defaults to disabled The approach is not yet very satisfactory IMHO, if you have suggestions, Pull Requests are welcome :) ## Example: iex> types = %{url: :string} iex> params = %{url: "https://www.example.com/"} iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types)) ...> |> validate_url(:url) #Ecto.Changeset iex> types = %{url: :string} iex> params = %{url: "https://www.example.com/"} iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types)) ...> |> validate_url(:url, checks: [:empty, :path, :scheme, :host]) #Ecto.Changeset iex> types = %{url: :string} iex> params = %{url: "some@invalid_url"} iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types)) ...> |> validate_url(:url) #Ecto.Changeset iex> types = %{url: :string} iex> params = %{url: "Just some random text"} iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types)) ...> |> validate_url(:url) #Ecto.Changeset """ import Ecto.Changeset # Taken from here https://mathiasbynens.be/demo/url-regex # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength @http_regex ~r/^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!127(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)(?:\.(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)*(?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/ius def validate_url(changeset, field, opts \\ []) do validate_change(changeset, field, {:url, opts}, fn _, value -> checks = Keyword.get(opts, :checks, [:parsable, :empty, :scheme, :host]) parsed = URI.parse(value) case do_validate_url(value, parsed, checks) do :ok -> [] :error -> [{field, {message(opts, "is not a valid url"), [validation: :url]}}] end end) end defp do_validate_url(value, parsed, checks) when is_list(checks) do check_all = Enum.map(checks, fn check -> do_validate_url(value, parsed, check) end) if Enum.member?(check_all, :error), do: :error, else: :ok end defp do_validate_url(value, _parsed, :parsable) do case URI.new(value) do {:ok, _uri} -> :ok {:error, _msg} -> :error end end defp do_validate_url(value, _parsed, :http_regexp) do case String.match?(value, @http_regex) do true -> :ok false -> :error end end # Caution: this check does a network call and can be slow. defp do_validate_url(_value, %URI{host: host}, :valid_host) do case :inet.gethostbyname(String.to_charlist(host)) do {:ok, _value} -> :ok {:error, :nxdomain} -> :error end end defp do_validate_url(_value, parsed, :empty) do values = parsed |> Map.from_struct() |> Enum.map(fn {_key, val} -> blank?(val) end) if Enum.member?(values, false), do: :ok, else: :error end defp do_validate_url(_value, %URI{path: path} = _parsed, :path) do if blank?(path), do: :error, else: :ok end defp do_validate_url(_value, %URI{scheme: scheme} = _parsed, :scheme) do if blank?(scheme), do: :error, else: :ok end defp do_validate_url(_value, %URI{host: host} = _parsed, :host) do if blank?(host), do: :error, else: :ok end defp message(opts, key \\ :message, default) do Keyword.get(opts, key, default) end @compile {:inline, blank?: 1} def blank?(""), do: true def blank?([]), do: true def blank?(nil), do: true def blank?({}), do: true def blank?(%{} = map) when map_size(map) == 0, do: true def blank?(_), do: false end