validate.ex (9960B)
1 # Zenflows is designed to implement the Valueflows vocabulary, 2 # written and maintained by srfsh <info@dyne.org>. 3 # Copyright (C) 2021-2023 Dyne.org foundation <foundation@dyne.org>. 4 # 5 # This program is free software: you can redistribute it and/or modify 6 # it under the terms of the GNU Affero General Public License as published by 7 # the Free Software Foundation, either version 3 of the License, or 8 # (at your option) any later version. 9 # 10 # This program is distributed in the hope that it will be useful, 11 # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 # GNU Affero General Public License for more details. 14 # 15 # You should have received a copy of the GNU Affero General Public License 16 # along with this program. If not, see <https://www.gnu.org/licenses/>. 17 18 defmodule Zenflows.Validate do 19 @moduledoc "Ecto.Changeset validation helpers." 20 21 alias Ecto.Changeset 22 23 require Logger 24 25 @typedoc """ 26 Used to specify whether to fetch the field's value from the `:changes`, 27 `:data`, or `:both` of an `Ecto.Changeset`, respectively. 28 29 Basically, uses `Ecto.Changeset.fetch_field/2` behind the scenes, 30 but `nil` values will mean empty/not-provided. 31 """ 32 @type fetch_method() :: :change | :data | :both 33 34 @doc """ 35 Escape possible characters that could represent expressions in SQL's 36 `LIKE` keyword of a field. The value is changed if it is present. 37 """ 38 @spec escape_like(Changeset.t(), atom()) :: Changeset.t() 39 def escape_like(cset, field) do 40 case Changeset.fetch_change(cset, :field) do 41 {:ok, v} -> 42 v = Regex.replace(~r/\\|%|_/, v, &"\\#{&1}") 43 Changeset.put_change(cset, field, v) 44 :error -> cset 45 end 46 end 47 48 @doc """ 49 Use the OR logic on the existance of `fields`, and error if the 50 result is false. 51 52 Note: `fields` must contain at least 2 items, and make sure to read 53 `t:fetch_method()`'s doc. 54 """ 55 @spec exist_or(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t() 56 def exist_or(cset, fields, opts \\ []) do 57 meth = Keyword.get(opts, :method, :change) 58 if Enum.any?(fields, &field_exists?(meth, cset, &1)) do 59 cset 60 else 61 Enum.reduce(fields, cset, 62 &Changeset.add_error(&2, &1, "at least one of them must be provided")) 63 end 64 end 65 66 @doc """ 67 Use the XOR logic on the existance of `fields`, and error if the 68 result is false. 69 70 Note: `fields` must contain at least 2 items, and make sure to read 71 `t:fetch_method()`'s doc. 72 """ 73 @spec exist_xor(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t() 74 def exist_xor(cset, [h | t] = fields, opts \\ []) do 75 meth = Keyword.get(opts, :method, :change) 76 h_exists? = field_exists?(meth, cset, h) 77 78 if Enum.all?(t, &(h_exists? != field_exists?(meth, cset, &1))) do 79 cset 80 else 81 Enum.reduce(fields, cset, 82 &Changeset.add_error(&2, &1, "exactly one of them must be provided")) 83 end 84 end 85 86 @doc """ 87 Use the NAND logic on the existance of `fields`, and error if the 88 result is false. 89 90 Note: `fields` must contain at least 2 items, and make sure to 91 read `t:fetch_method()`'s doc. 92 """ 93 @spec exist_nand(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t() 94 def exist_nand(cset, fields, opts \\ []) do 95 meth = Keyword.get(opts, :method, :change) 96 if Enum.all?(fields, &field_exists?(meth, cset, &1)) do 97 Enum.reduce(fields, cset, 98 &Changeset.add_error(&2, &1, "one or none of them must be provided")) 99 else 100 cset 101 end 102 end 103 104 @doc """ 105 Compare the values of `fields`, and error if they aren't equal to 106 each other. 107 108 Note: `fields` must contain at least 2 items, and make sure to read 109 `t:fetch_method()`'s doc. Also, empty fields will be skipped to 110 allow more flexibility. Use `Ecto.Changeset.validate_required/3` 111 to achieve otherwise. 112 """ 113 @spec value_eq(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t() 114 def value_eq(cset, [a, b], opts \\ []) do 115 meth = Keyword.get(opts, :method, :change) 116 with {:ok, x} <- field_fetch(meth, cset, a), 117 {:ok, y} <- field_fetch(meth, cset, b) do 118 if x == y do 119 cset 120 else 121 msg = "all of them must be the same" 122 cset 123 |> Changeset.add_error(a, msg) 124 |> Changeset.add_error(b, msg) 125 end 126 else _ -> 127 cset 128 end 129 end 130 131 @doc """ 132 Compare the values of `fields`, and error if they aren't all 133 different. 134 135 Note: `fields` must contain at least 2 items, and make sure to read 136 `t:fetch_method()`'s doc. Also, empty fields will be skipped to 137 allow more flexibility. Use `Ecto.Changeset.validate_required/3` 138 to achieve otherwise. 139 """ 140 @spec value_ne(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t() 141 def value_ne(cset, [a, b], opts \\ []) do 142 meth = Keyword.get(opts, :method, :change) 143 with {:ok, x} <- field_fetch(meth, cset, a), 144 {:ok, y} <- field_fetch(meth, cset, b) do 145 if x != y do 146 cset 147 else 148 msg = "all of them must be different" 149 cset 150 |> Changeset.add_error(a, msg) 151 |> Changeset.add_error(b, msg) 152 end 153 else _ -> 154 cset 155 end 156 end 157 158 @doc "Validate that given `field` is a valid email address." 159 @spec email(Changeset.t(), atom()) :: Changeset.t() 160 def email(cset, field) do 161 # works good enough for now 162 Changeset.validate_format(cset, field, ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/) 163 end 164 165 @doc """ 166 Validate that given `field` is [1, 256] bytes long. 167 168 The name "name" is a reference to short texts, as in email subject, 169 usernames, full names, and so on. 170 """ 171 @spec name(Changeset.t(), atom()) :: Changeset.t() 172 def name(cset, field), do: byte_range(cset, field, 1, 256) 173 174 @doc """ 175 Validate that given `field` is [1, 2048] bytes long. 176 177 The name "note" is a reference to long texts, as in email bodies, 178 descriptions, notes, and so on. 179 """ 180 @spec note(Changeset.t(), atom()) :: Changeset.t() 181 def note(cset, field), do: byte_range(cset, field, 1, 2048) 182 183 @doc """ 184 Validate that given `field` is [16, 2048] bytes long. 185 186 The name "key" is a reference to encoded cryptographic keys (so the 187 size is not the size of the binary key). 188 """ 189 @spec key(Changeset.t(), atom()) :: Changeset.t() 190 def key(cset, field), do: byte_range(cset, field, 16, 2048) 191 192 @doc """ 193 Validate that given `field` is [1, 512] bytes long. 194 195 The name "uri" is a reference to not-literal URIs, but a size used 196 for mostly URIs. 197 """ 198 @spec uri(Changeset.t(), atom()) :: Changeset.t() 199 def uri(cset, field), do: byte_range(cset, field, 1, 512) 200 201 @doc """ 202 Validate that given `field` is [1B, 25MiB] long, and log a warning 203 if it is longer than 4MiB. 204 205 The name "img" is a reference to Base64-encoded image binary data. 206 """ 207 @spec img(Changeset.t(), atom()) :: Changeset.t() 208 def img(cset, field) do 209 Changeset.validate_change(cset, field, __MODULE__, fn 210 _, str when byte_size(str) < 1 -> 211 [{field, "should be at least 1B long"}] 212 _, str when byte_size(str) > 25 * 1024 * 1024 -> 213 [{field, "should be at most 25MiB long"}] 214 _, str when byte_size(str) > 4 * 1024 * 1024 -> 215 Logger.warning("file exceeds 4MiB") 216 [] 217 _, _ -> 218 [] 219 end) 220 end 221 222 @doc """ 223 Validate that given `field` is a list of binaries, which are [1, 224 512] bytes long in size, and the list itself contains [1, 128] 225 items. 226 227 The name "class" is a reference to list of strings used for tagging, 228 categorization and so on. 229 """ 230 @spec class(Changeset.t(), atom()) :: Changeset.t() 231 def class(cset, field) do 232 Changeset.validate_change(cset, field, __MODULE__, fn 233 _, [] -> 234 [{field, "must contain at least 1 item"}] 235 _, list -> 236 case do_class(list, 0, 128) do 237 {:exceeds, _ind} -> 238 [{field, "must contain at most 128 items"}] 239 {:short, ind} -> 240 [{field, "the item at #{ind + 1} cannot be shorter than 1 byte"}] 241 {:long, ind} -> 242 [{field, "the item at #{ind + 1} cannot be longer than 512 bytes"}] 243 {:valid, _ind} -> 244 [] 245 end 246 end) 247 end 248 249 # The rationale of this function is to loop over the list while 250 # decreasing `rem` (reminder) and increasing `ind` (index) until 251 # either one of these happen (in that order): 252 # 253 # 1. `rem` is equal to 0 254 # 2. one of the items in the list is shorter than 1 byte long 255 # 3. one of the items in the list is longer than 512 bytes long 256 # 257 @spec do_class([String.t()], non_neg_integer(), non_neg_integer()) 258 :: {:exceeds | :short | :long | :valid, non_neg_integer()} 259 defp do_class([], ind, _), do: {:valid, ind - 1} 260 defp do_class([h | t], ind, rem) do 261 cond do 262 rem == 0 -> {:exceeds, ind} 263 byte_size(h) < 1 -> {:short, ind} 264 byte_size(h) > 512 -> {:long, ind} 265 true -> do_class(t, ind + 1, rem - 1) 266 end 267 end 268 269 @doc """ 270 Validate that the given binary (in Elixir terms) is in this inclusive 271 octects/bytes range. 272 """ 273 @spec byte_range(Changeset.t(), atom(), non_neg_integer(), non_neg_integer()) 274 :: Changeset.t() 275 def byte_range(cset, field, min, max) do 276 Changeset.validate_change(cset, field, __MODULE__, fn 277 _, bin when byte_size(bin) < min -> 278 [{field, "should be at least #{min} byte(s) long"}] 279 _, bin when byte_size(bin) > max -> 280 [{field, "should be at most #{max} byte(s) long"}] 281 _, _ -> 282 [] 283 end) 284 end 285 286 @spec field_exists?(fetch_method(), Changeset.t(), atom()) :: boolean() 287 defp field_exists?(:change, cset, field) do 288 case Changeset.fetch_field(cset, field) do 289 {:changes, x} when not is_nil(x) -> true 290 _ -> false 291 end 292 end 293 defp field_exists?(:data, cset, field) do 294 case Changeset.fetch_field(cset, field) do 295 {:data, x} when not is_nil(x) -> true 296 _ -> false 297 end 298 end 299 defp field_exists?(:both, cset, field) do 300 case Changeset.fetch_field(cset, field) do 301 {:changes, x} when not is_nil(x) -> true 302 {:data, x} when not is_nil(x) -> true 303 _ -> false 304 end 305 end 306 307 @spec field_fetch(fetch_method(), Changeset.t(), atom()) :: {:ok, term()} | :error 308 defp field_fetch(:change, cset, field) do 309 case Changeset.fetch_field(cset, field) do 310 {:changes, v} -> {:ok, v} 311 _ -> :error 312 end 313 end 314 defp field_fetch(:data, cset, field) do 315 case Changeset.fetch_field(cset, field) do 316 {:data, v} -> {:ok, v} 317 _ -> :error 318 end 319 end 320 defp field_fetch(:both, cset, field) do 321 case Changeset.fetch_field(cset, field) do 322 :error -> :error 323 {_, v} -> {:ok, v} 324 end 325 end 326 end