defmodule Haytni do @moduledoc ~S""" Documentation for Haytni. """ @application :haytni @type user :: struct @type config :: any @type irrelevant :: any @type duration_unit :: :second | :minute | :hour | :day | :week | :month | :year @type duration :: pos_integer | {pos_integer, duration_unit} @type params :: %{optional(String.t) => String.t} @type repo_nobang_operation(type) :: {:ok, type} | {:error, Ecto.Changeset.t} @type multi_result :: {:ok, %{required(Ecto.Multi.name) => any}} | {:error, Ecto.Multi.name, any, %{optional(Ecto.Multi.name) => any}} @spec app_base(atom | module) :: String.t defp app_base(app) do case Application.get_env(app, :namespace, app) do ^app -> app |> to_string() |> Phoenix.Naming.camelize() mod -> mod |> inspect() end end defp fetch_env!(key) do Application.fetch_env!(@application, key) end defmacro __using__(options) do otp_app = Keyword.fetch!(options, :otp_app) web_module = otp_app |> app_base() |> Kernel.<>("Web") |> String.to_atom() quote do import unquote(__MODULE__) Module.register_attribute(__MODULE__, :plugins, accumulate: true) @behaviour Plug @before_compile unquote(__MODULE__) @spec otp_app() :: atom def otp_app do unquote(otp_app) end @spec web_module() :: atom def web_module do unquote(web_module) end @spec router() :: module def router do unquote(Module.concat([web_module, :Router, :Helpers])) end @spec endpoint() :: module def endpoint do unquote(Module.concat([web_module, :Endpoint])) end @spec schema() :: module def schema do unquote(fetch_env!(__CALLER__.module)[:schema]) end @spec repo() :: module def repo do unquote(fetch_env!(__CALLER__.module)[:repo]) end @spec mailer() :: module def mailer do unquote(fetch_env!(__CALLER__.module)[:mailer]) end @spec layout() :: false | {module, atom} def layout do unquote(Keyword.get(fetch_env!(__CALLER__.module), :layout, false)) end @spec scope() :: atom def scope do unquote(Keyword.get(fetch_env!(__CALLER__.module), :scope, :user)) end @impl Plug def init(_options), do: nil @impl Plug def call(conn, _options) do scope = :"current_#{scope()}" if Map.get(conn.assigns, scope) do conn else {conn, user} = Haytni.find_user(__MODULE__, conn) Plug.Conn.assign(conn, scope, user) end |> Plug.Conn.put_private(:haytni, __MODULE__) end defmacro routes(options \\ []) do unquote(__MODULE__).routes(__MODULE__, options) end defmacro fields do unquote(__MODULE__).fields(__MODULE__) end #def plugin_enabled?(module) do #def create_user(attrs = %{}, options \\ []) do #def update_registration(user = %_{}, attrs = %{}, options \\ []) do #def authentication_failed(user = nil) do #def authentication_failed(user = %_{}) do #def update_user_with(user = %_{}, changes) do #def update_user_with!(user = %_{}, changes) do def validate_create_registration(changeset) do unquote(__MODULE__).validate_create_registration(__MODULE__, changeset) end def validate_update_registration(changeset) do unquote(__MODULE__).validate_update_registration(__MODULE__, changeset) end def validate_password(changeset) do unquote(__MODULE__).validate_password(__MODULE__, changeset) end end end defmacro stack(module, options \\ []) do quote do Module.put_attribute(__MODULE__, :plugins, {unquote(module), unquote(Macro.escape(options))}) end end @doc false defmacro __before_compile__(env) do plugins_with_config = env.module |> Module.get_attribute(:plugins) |> Enum.map( fn {plugin, options} -> {plugin, Macro.escape(plugin.build_config(options))} end ) defs = plugins_with_config |> Enum.map( fn {plugin, config} -> quote do def fetch_config(unquote(plugin)) do unquote(config) end end end ) quote do # an "idea" to replace module + config arguments? @spec __config__() :: %{ #required(:router) => module, required(:mailer) => module, #required(:web_module) => module, required(:repo) => module, required(:schema) => module, #required(:opt_app) => atom, required(:self) => module, required(:layout) => any, required(:plugins) => [module], } def __config__ do %{ layout: false, self: __MODULE__, #otp_app: unquote(otp_app), #web_module: unquote(web_module), repo: unquote(fetch_env!(__CALLER__.module)[:repo]), mailer: unquote(fetch_env!(__CALLER__.module)[:mailer]), schema: unquote(fetch_env!(__CALLER__.module)[:schema]), #router: unquote(Module.concat([web_module, :Router, :Helpers])), plugins: unquote(Enum.map(plugins_with_config, &(elem(&1, 0)))), } end @spec fetch_config(plugin :: module) :: any unquote(defs) def fetch_config(_), do: nil @spec plugins() :: [module] def plugins do unquote(Enum.map(plugins_with_config, &(elem(&1, 0)))) end @spec plugins_with_config() :: Keyword.t def plugins_with_config do unquote(plugins_with_config) end end end @doc ~S""" Get the list of shared (templates/views) or independant (Haytni stack) files to install """ @spec shared_files_to_install(base_path :: String.t, web_path :: String.t, scope :: String.t, timestamp :: String.t) :: [{:eex | :text, String.t, String.t}] def shared_files_to_install(base_path, web_path, scope, _timestamp) do [ {:eex, "haytni.ex", Path.join([base_path, "haytni.ex"])}, {:eex, "views/shared_view.ex", Path.join([web_path, "views", "haytni", scope, "shared_view.ex"])}, {:eex, "templates/shared/keys.html.eex", Path.join([web_path, "templates", "haytni", scope, "shared", "keys.html.eex"])}, {:eex, "templates/shared/links.html.eex", Path.join([web_path, "templates", "haytni", scope, "shared", "links.html.eex"])}, {:eex, "templates/shared/message.html.eex", Path.join([web_path, "templates", "haytni", scope, "shared", "message.html.eex"])}, # test {:eex, "tests/haytni_quick_views_and_templates_test.exs", Path.join([base_path, "..", "..", "test", "haytni", "haytni_quick_views_and_templates_test.exs"])}, ] end @doc ~S""" Returns `true` if *plugin* is enabled in the *module* Haytni stack. """ @spec plugin_enabled?(module :: module, plugin :: module) :: boolean def plugin_enabled?(module, plugin) do plugin in module.plugins() end # Returns the first non-falsy (`nil` in particular) resulting of calling *fun/2* for each element of *list* or *default* if all elements of (keyword) *list* returned a falsy value. @spec map_while(list :: Keyword.t, default :: any, fun :: (atom, any -> any)) :: any defp map_while(list, default, fun) do try do for {k, v} <- list do v = fun.(k, v) if v do throw v end end catch val -> val else _ -> default end end defp find_user([{plugin, config} | tl], conn, module) do result = {conn, user} = plugin.find_user(conn, module, config) if user do result else find_user(tl, conn, module) end end defp find_user([], conn, _module) do {conn, nil} end @spec invalid_user?(module :: module, user :: Haytni.user) :: {:error, String.t} | false defp invalid_user?(module, user = %_{}) do module.plugins_with_config() |> map_while(false, &(&1.invalid?(user, module, &2))) end @doc ~S""" Used by plug to extract the current user (if any) from the HTTP request (meaning from headers, cookies, etc) """ @spec find_user(module :: module, conn :: Plug.Conn.t) :: {Plug.Conn.t, Haytni.user | nil} def find_user(module, conn = %Plug.Conn{}) do scoped_session_key = :"#{module.scope()}_id" {conn, user, from_session?} = case Plug.Conn.get_session(conn, scoped_session_key) do nil -> find_user(module.plugins_with_config(), conn, module) |> Tuple.append(false) id -> {conn, get_user(module, id), true} end if user do case invalid_user?(module, user) do {:error, _error} -> {Plug.Conn.delete_session(conn, scoped_session_key), nil} false -> if from_session? do {conn, user} else {:ok, %{conn: conn, user: user}} = on_successful_authentication(module, conn, user) conn = conn |> Plug.Conn.put_session(scoped_session_key, user.id) |> Plug.Conn.configure_session(renew: true) {conn, user} end end else {conn, nil} end end @doc ~S""" Register user from controller's *params*. Returned value is one of: * `{:ok, map}` where *map* is the result of the internals `Ecto.Multi.*` calls * `{:error, failed_operation, result_of_failed_operation, changes_so_far}` with: + *failed_operation*: the name of the operation which failed + *result_of_failed_operation*: its result/returned value + *changes_so_far*: same as *map* of the `{:ok, map}` case The inserted user will be part of *map* (or eventualy *changes_so_far*) under the key `:user`. See `c:Ecto.Repo.insert/2` for *options*. """ @spec create_user(module :: module, attrs :: map, options :: Keyword.t) :: Haytni.multi_result def create_user(module, attrs = %{}, options \\ []) do schema = module.schema() changeset = schema |> struct() |> schema.create_registration_changeset(attrs) multi = Ecto.Multi.new() |> Ecto.Multi.insert(:user, changeset, options) module.plugins_with_config() |> Enum.reduce( multi, fn {plugin, config}, multi_as_acc -> plugin.on_registration(multi_as_acc, module, config) end ) |> module.repo().transaction() end @doc ~S""" Runs any custom password validations from the plugins (via their `validate_password/2` callback) of the *module* Haytni stack. An `%Ecto.Changeset{}` is returned with the potential validation errors added by the plugins. Do **NOT** call it from your function `validate_update_registration/2`, it will be called internally only if needed. """ @spec validate_password(module :: module, changeset :: Ecto.Changeset.t) :: Ecto.Changeset.t def validate_password(module, changeset) do module.plugins_with_config() |> Enum.reduce( changeset, fn {plugin, config}, changeset = %Ecto.Changeset{} -> plugin.validate_password(changeset, module, config) end ) end @spec handle_email_change(module :: module, multi :: Ecto.Multi.t, changeset :: Ecto.Changeset.t) :: {Ecto.Multi.t, Ecto.Changeset.t} defp handle_email_change(module, multi = %Ecto.Multi{}, changeset = %Ecto.Changeset{changes: %{email: new_email}}) do multi = multi |> Ecto.Multi.run( :new_email, fn _repo, %{} -> {:ok, new_email} end ) |> Ecto.Multi.run( :old_email, fn _repo, %{} -> {:ok, changeset.data.email} end ) module.plugins_with_config() |> Enum.reduce( {multi, changeset}, fn {plugin, config}, {multi = %Ecto.Multi{}, changeset = %Ecto.Changeset{}} -> plugin.on_email_change(multi, changeset, module, config) end ) end defp handle_email_change(_module, multi = %Ecto.Multi{}, changeset = %Ecto.Changeset{}), do: {multi, changeset} @spec handle_password_change(module :: module, changeset :: Ecto.Changeset.t) :: Ecto.Changeset.t defp handle_password_change(module, changeset = %Ecto.Changeset{changes: %{password: _}}) do validate_password(module, changeset) end defp handle_password_change(_module, changeset = %Ecto.Changeset{}), do: changeset @doc ~S""" Update user's registration, its own registration. Works exactly as `create_user`. The only difference is the additionnal parameter: the user to update as first one. NOTE: the callbacks of `Ecto.Multi.run` added to the multi by the `on_email_change/4` callback will receive from the `Map` they get as their (single) argument the following predefined elements: * the updated user as the `:user` key * the previous email as `:old_email` * `:new_email`: the new email """ @spec update_registration(module :: module, user :: Haytni.user, attrs :: map, options :: Keyword.t) :: Haytni.multi_result def update_registration(module, user = %_{}, attrs = %{}, options \\ []) do changeset = user |> module.schema().update_registration_changeset(attrs) changeset = handle_password_change(module, changeset) # TODO: better to be done in RegisterablePlugin.validate_(update|create)_registration? {multi = %Ecto.Multi{}, changeset} = handle_email_change(module, Ecto.Multi.new(), changeset) # TODO: better to be done in RegisterablePlugin.validate_(update|create)_registration? # create a multi to update user and merge into it the multi from plugins then execute it Ecto.Multi.new() |> Ecto.Multi.update(:user, changeset, options) |> Ecto.Multi.append(multi) |> module.repo().transaction() end @doc ~S""" Injects the necessary routes for enabled plugins into your Router Note that this function is invoked at compile time: you'll need to recompile your application to reflect any change in your router. """ def routes(module, options \\ []) do as = case module.scope() do nil -> :haytni scope -> :"haytni_#{scope}" end module.plugins() |> Enum.map(&(&1.routes(as, options))) end @doc ~S""" Injects `Ecto.Schema.field`s necessary to enabled plugins into your User schema Note that this function is invoked at compile time: you'll need to recompile your application to reflect any change related to fields injected in your user schema. """ def fields(module) do module.plugins() |> Enum.map(&(&1.fields(module))) end @doc ~S""" Notifies plugins that current user is going to be logged out """ @spec logout(conn :: Plug.Conn.t, module :: module, options :: Keyword.t) :: Plug.Conn.t def logout(conn = %Plug.Conn{}, module, options \\ []) do conn = module.plugins_with_config() |> Enum.reverse() |> Enum.reduce(conn, fn {plugin, config}, conn -> plugin.on_logout(conn, module, config) end) case Keyword.get(options, :scope) do :all -> Plug.Conn.clear_session(conn) #Plug.Conn.configure_session(conn, drop: true) _ -> conn |> Plug.Conn.configure_session(renew: true) |> Plug.Conn.delete_session(:"#{module.scope()}_id") end end @spec on_successful_authentication(module :: module, conn :: Plug.Conn.t, user :: Haytni.user) :: Haytni.multi_result defp on_successful_authentication(module, conn, user) do {conn, multi, changes} = module.plugins_with_config() |> Enum.reduce( {conn, Ecto.Multi.new(), Keyword.new()}, fn {plugin, config}, {conn, multi, changes} -> plugin.on_successful_authentication(conn, user, multi, changes, module, config) end ) Ecto.Multi.new() |> Ecto.Multi.run(:conn, fn _repo, %{} -> {:ok, conn} end) |> Ecto.Multi.update(:user, Ecto.Changeset.change(user, changes)) |> Ecto.Multi.append(multi) |> module.repo().transaction() end @doc ~S""" To be called on (manual) login """ @spec login(conn :: Plug.Conn.t, module :: module, user :: Haytni.user) :: {:ok, Plug.Conn.t} | {:error, String.t} def login(conn = %Plug.Conn{}, module, user = %_{}) do case invalid_user?(module, user) do error = {:error, _message} -> error false -> {:ok, %{conn: conn, user: user}} = on_successful_authentication(module, conn, user) conn = conn |> Plug.Conn.put_session(:"#{module.scope()}_id", user.id) |> Plug.Conn.configure_session(renew: true) |> Plug.Conn.assign(:"current_#{module.scope()}", user) {:ok, conn} end end @doc ~S""" Notifies plugins that the authentication failed for *user*. If *user* is `nil`, nothing is done. """ @spec authentication_failed(module :: module, user :: Haytni.user | nil) :: Haytni.multi_result def authentication_failed(_module, user = nil) do # NOP, for convenience {:ok, %{user: user}} end def authentication_failed(module, user = %_{}) do {multi, changes} = module.plugins_with_config() |> Enum.reduce( {Ecto.Multi.new(), Keyword.new()}, fn {plugin, config}, {multi, keywords} -> plugin.on_failed_authentication(user, multi, keywords, module, config) end ) Ecto.Multi.new() |> Ecto.Multi.update(:user, Ecto.Changeset.change(user, changes)) |> Ecto.Multi.append(multi) |> module.repo().transaction() end @doc ~S""" This function is a callback to be called from your `User.create_registration_changeset/2` so validations and others internal tasks can be done by plugins at user's registration. """ @spec validate_create_registration(module :: module, changeset :: Ecto.Changeset.t) :: Ecto.Changeset.t def validate_create_registration(module, changeset = %Ecto.Changeset{}) do module.plugins_with_config() |> Enum.reduce(changeset, fn {plugin, config}, changeset -> plugin.validate_create_registration(changeset, module, config) end) end @doc ~S""" Same than `validate_update_registration/2` but at registration's edition. """ @spec validate_update_registration(module :: module, changeset :: Ecto.Changeset.t) :: Ecto.Changeset.t def validate_update_registration(module, changeset = %Ecto.Changeset{}) do module.plugins_with_config() |> Enum.reduce(changeset, fn {plugin, config}, changeset -> plugin.validate_update_registration(changeset, module, config) end) end defp user_and_changes_to_changeset(user, changes) do Ecto.Changeset.change(user, changes) end @doc ~S""" Update the given user from a list of changes as `Keyword`. Returns `{:error, changeset}` if there was a validation or a known constraint error else `{:ok, struct}` where *struct* is the updated user. NOTE: for internal use, there isn't any validation. Do **NOT** inject values from controller's *params*! """ @spec update_user_with(module :: module, user :: Haytni.user, changes :: Keyword.t) :: Haytni.repo_nobang_operation(Haytni.user) def update_user_with(module, user = %_{}, changes) do user |> user_and_changes_to_changeset(changes) |> module.repo().update() end @doc ~S""" Same as `update_user_with/3` but returns the updated *user* struct or raises if *changes* are invalid. """ @spec update_user_with!(module :: module, user :: Haytni.user, changes :: Keyword.t) :: Haytni.user | no_return def update_user_with!(module, user = %_{}, changes) do user |> user_and_changes_to_changeset(changes) |> module.repo().update!() end @doc ~S""" Fetches a user from the *Ecto.Repo* specified in `config :haytni, YourApp.Haytni` as `repo` subkey via the attributes specified by *clauses* as a map or a keyword-list. Returns `nil` if no user matches. Example: hulk = Haytni.get_user_by(YourApp.Haytni, first_name: "Robert", last_name: "Banner") """ @spec get_user_by(module :: module, clauses :: Keyword.t | map) :: Haytni.user | nil def get_user_by(module, clauses) do module.repo().get_by(module.schema(), clauses) end @doc ~S""" Fetchs a user from its id. Returns `nil` if no user matches. Example: case Haytni.get_user_by(YourApp.Haytni, params["id"]) do nil -> # not found user = %User{} -> # do something of user end """ @spec get_user(module :: module, id :: any) :: Haytni.user | nil def get_user(module, id) do module.repo().get(module.schema(), id) end @doc ~S""" Creates an `%Ecto.Changeset{}` for a new user/account (at registration from a module) or from a user (when editing account from a struct) """ @spec change_user(user_or_module :: module | Haytni.user, params :: Haytni.params) :: Ecto.Changeset.t def change_user(user_or_module, params \\ %{}) def change_user(module, params) when is_atom(module) do user = module.schema() |> struct() user.__struct__.create_registration_changeset(user, params) end def change_user(user = %_{}, params) do user.__struct__.update_registration_changeset(user, params) end @doc ~S""" Extracts an Haytni stack (module) from a Plug connection Raises if no Haytni's stack was defined (through the router). """ @spec fetch_module_from_conn!(conn :: Plug.Conn.t) :: module def fetch_module_from_conn!(conn = %Plug.Conn{}) do Map.fetch!(conn.private, :haytni) end end