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 nilable(type) :: type | nil
@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()
scope =
__CALLER__.module
|> fetch_env!()
|> Keyword.get(:scope, :user)
scoped_assign = :"current_#{scope}"
scoped_session_key = :"#{scope}_id"
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(scope)
end
@spec scoped_assign() :: atom
def scoped_assign do
unquote(scoped_assign)
end
@spec scoped_session_key() :: atom
def scoped_session_key do
unquote(scoped_session_key)
end
@impl Plug
def init(_options), do: nil
@impl Plug
def call(conn = %Plug.Conn{private: %{haytni: module}}, _options) do
raise ArgumentError, """
More than one Haytni stack can't be applied to a same URL. A review of your router is required.
If you have defined several stacks in a same router, it is required to replace:
pipeline :browser do
# ...
plug #{inspect(module)}
plug #{inspect(__MODULE__)}
# ...
end
By distinct pipelines. One way to do it is as follows:
scope "..." do
pipe_through [:browser, #{inspect(module)}]
# ...
end
scope "..." do
pipe_through [:browser, #{inspect(__MODULE__)}]
# ...
end
"""
end
def call(conn, _options) do
if Map.get(conn.assigns, unquote(scoped_assign)) do
conn
else
{conn, user} = Haytni.find_user(__MODULE__, conn)
Plug.Conn.assign(conn, unquote(scoped_assign), user)
end
|> Plug.Conn.put_private(:haytni, __MODULE__)
end
def on_mount(_, _params, session, socket) do
{
:cont,
socket
|> Phoenix.LiveView.assign_new(
unquote(scoped_assign),
fn ->
with(
id when not is_nil(id) <- Map.get(session, unquote(scoped_session_key |> to_string())),
user = %_{} <- Haytni.get_user(__MODULE__, id),
false <- Haytni.invalid_user?(__MODULE__, user)
) do
user
else
_ ->
nil
end
end
)
}
end
defmacro routes(options \\ []) do
routes = unquote(__MODULE__).routes(__MODULE__, options)
quote do
scope as: false, alias: false do
unquote(routes)
end
end
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.expand(options, __ENV__))})
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
if false 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
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.heex", Path.join([web_path, "templates", "haytni", scope, "shared", "keys.html.heex"])},
{:eex, "templates/shared/links.html.heex", Path.join([web_path, "templates", "haytni", scope, "shared", "links.html.heex"])},
{:eex, "templates/shared/message.html.heex", Path.join([web_path, "templates", "haytni", scope, "shared", "message.html.heex"])},
# migration
{:eex, "migrations/0-tokens_creation.exs", Path.join([web_path, "..", "..", "priv", "repo", "migrations", "#{timestamp}_haytni_#{scope}_tokens_creation.exs"])},
# 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
@doc ~S"""
Returns the name of the session key which carries the user
"""
# NOTE: we return a binary instead of an atom for compatibility with LiveView were session keys should be strings
@spec scoped_session_key(module :: module) :: String.t
def scoped_session_key(module)
when is_atom(module)
do
module.scoped_session_key()
end
@doc ~S"""
Returns the name of the assign (in Plug.Conn and templates) of the current user
"""
@spec scoped_assign(module :: module) :: atom
def scoped_assign(module)
when is_atom(module)
do
module.scoped_assign()
end
@doc ~S"""
Checks if a user is valid according to plugins.
Returns `false` if the user is valid else `{:error, reason}`.
"""
@spec invalid_user?(module :: module, user :: Haytni.user) :: {:error, String.t} | false
def 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 = scoped_session_key(module)
{conn, user, from_session?} = case Plug.Conn.get_session(conn, scoped_session_key) do
nil ->
module.plugins_with_config()
|> find_user(conn, module)
#|> Enum.reduce_while(
#{conn, nil},
#fn plugin, {conn, _user} ->
#acc = {conn, user} = plugin.find_user(conn, module, config)
#if conn.halted? or not is_nil(user) ->
#{:halt, acc}
#else
#{:cont, acc}
#end
#end
#)
|> 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)
Plug.CSRFProtection.delete_csrf_token()
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 `c:Haytni.Plugin.validate_password/3` callback) of
the *module* Haytni stack. An `%Ecto.Changeset{}` is returned with the potential validation errors added by the plugins.
"""
@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
@doc ~S"""
Function to be called, for the user, to modify its own email address: it actually updates it in the database but also
invokes, first, the `c:Haytni.Plugin.on_email_change/4` callback of the plugins registered in the *module* Haytni stack.
Note: caller is responsible for validations against *new_email_address*
"""
@spec email_changed(module :: module, user :: Haytni.user, new_email_address :: String.t) :: Haytni.multi_result
def email_changed(module, user = %_{}, new_email_address)
when is_binary(new_email_address)
do
multi =
Ecto.Multi.new()
|> Ecto.Multi.put(:old_email, user.email)
|> Ecto.Multi.put(:new_email, new_email_address)
changeset =
user
# TODO: here a unique constraint can still fail
|> Ecto.Changeset.change(email: new_email_address)
{multi, changeset} =
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
)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.append(multi)
|> module.repo().transaction()
end
@doc ~S"""
Update user's registration, its own registration.
"""
@spec update_registration(module :: module, user :: Haytni.user, attrs :: map, options :: Keyword.t) :: Haytni.repo_nobang_operation(Haytni.user)
def update_registration(module, user = %_{}, attrs = %{}, options \\ []) do
user
|> module.schema().update_registration_changeset(attrs)
|> module.repo().update(options)
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_with_config()
|> Enum.map(
fn {module, config} ->
module.routes(config, as, options)
end
)
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.into([Haytni.Token.fields(module)], &(&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(scoped_session_key(module))
end
end
@spec on_successful_authentication(module :: module, conn :: Plug.Conn.t, user :: Haytni.user, changes :: Keyword.t) :: Haytni.multi_result
defp on_successful_authentication(module, conn, user, changes \\ []) do
{conn, multi, changes} =
module.plugins_with_config()
|> Enum.reduce(
{conn, Ecto.Multi.new(), changes},
fn {plugin, config}, {conn, multi, changes} ->
plugin.on_successful_authentication(conn, user, multi, changes, module, config)
end
)
Ecto.Multi.new()
|> Ecto.Multi.put(:conn, conn)
|> 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, changes :: Keyword.t) :: {:ok, Plug.Conn.t} | {:error, String.t}
def login(conn = %Plug.Conn{}, module, user = %_{}, changes \\ []) do
case invalid_user?(module, user) do
error = {:error, _message} ->
error
false ->
{:ok, %{conn: conn, user: user}} = on_successful_authentication(module, conn, user, changes)
Plug.CSRFProtection.delete_csrf_token()
conn =
conn
|> Plug.Conn.put_session(scoped_session_key(module), user.id)
|> Plug.Conn.configure_session(renew: true)
|> Plug.Conn.assign(scoped_assign(module), 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.nilable(Haytni.user)) :: 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/3` 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
@typep changes :: %{required(atom) => term} | nonempty_list({Keyword.key, Keyword.value})
@spec user_and_changes_to_changeset(user :: Haytni.user, changes :: Haytni.changes) :: Ecto.Changeset.t
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"""
Update user in the same way as `update_user_with/3` but as part of a set of operations (Ecto.Multi).
"""
@spec update_user_in_multi_with(multi :: Ecto.Multi.t, name :: Ecto.Multi.name, user :: Haytni.user, changes :: Keyword.t) :: Ecto.Multi.t
def update_user_in_multi_with(multi = %Ecto.Multi{}, name, user = %_{}, changes) do
Ecto.Multi.update(multi, name, user_and_changes_to_changeset(user, changes))
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.nilable(Haytni.user)
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.nilable(Haytni.user)
def get_user(module, id) do
module.repo().get(module.schema(), id)
end
@doc ~S"""
To delete *user*.
This function doesn't actually do nothing except calling the `c:Haytni.Plugin.on_delete_user/4` callbacks
from the plugins in the Haytni's *module* stack. This way you can implement it the way you like and do
extra stuffs like deleting files associated to *user*.
See documentation of `c:Haytni.Plugin.on_delete_user/4` for some examples.
"""
@spec delete_user(module :: module, user :: Haytni.user) :: Haytni.multi_result
def delete_user(module, user) do
module.plugins_with_config()
|> Enum.reduce(
Ecto.Multi.new(),
fn {plugin, config}, multi_as_acc ->
plugin.on_delete_user(multi_as_acc, user, module, config)
end
)
|> Haytni.Token.delete_tokens_in_multi(:tokens, user, :all)
|> module.repo().transaction()
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