rename to memex
This commit is contained in:
		
							
								
								
									
										479
									
								
								lib/memex/accounts.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										479
									
								
								lib/memex/accounts.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,479 @@ | ||||
| defmodule Memex.Accounts do | ||||
|   @moduledoc """ | ||||
|   The Accounts context. | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Memex.{Mailer, Repo} | ||||
|   alias Memex.Accounts.{User, UserToken} | ||||
|   alias Ecto.{Changeset, Multi} | ||||
|   alias Oban.Job | ||||
|  | ||||
|   ## Database getters | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a user by email. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_user_by_email("foo@example.com") | ||||
|       %User{} | ||||
|  | ||||
|       iex> get_user_by_email("unknown@example.com") | ||||
|       nil | ||||
|  | ||||
|   """ | ||||
|   @spec get_user_by_email(String.t()) :: User.t() | nil | ||||
|   def get_user_by_email(email) when is_binary(email), do: Repo.get_by(User, email: email) | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a user by email and password. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_user_by_email_and_password("foo@example.com", "correct_password") | ||||
|       %User{} | ||||
|  | ||||
|       iex> get_user_by_email_and_password("foo@example.com", "invalid_password") | ||||
|       nil | ||||
|  | ||||
|   """ | ||||
|   @spec get_user_by_email_and_password(String.t(), String.t()) :: | ||||
|           User.t() | nil | ||||
|   def get_user_by_email_and_password(email, password) | ||||
|       when is_binary(email) and is_binary(password) do | ||||
|     user = Repo.get_by(User, email: email) | ||||
|     if User.valid_password?(user, password), do: user | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single user. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the User does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_user!(123) | ||||
|       %User{} | ||||
|  | ||||
|       iex> get_user!(456) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_user!(User.t()) :: User.t() | ||||
|   def get_user!(id), do: Repo.get!(User, id) | ||||
|  | ||||
|   @doc """ | ||||
|   Returns all users grouped by role. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_users_by_role(%User{id: 123, role: :admin}) | ||||
|       [admin: [%User{}], user: [%User{}, %User{}]] | ||||
|  | ||||
|   """ | ||||
|   @spec list_all_users_by_role(User.t()) :: %{String.t() => [User.t()]} | ||||
|   def list_all_users_by_role(%User{role: :admin}) do | ||||
|     Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn user -> user.role end) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns all users for a certain role. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_users_by_role(%User{id: 123, role: :admin}) | ||||
|       [%User{}] | ||||
|  | ||||
|   """ | ||||
|   @spec list_users_by_role(:admin | :user) :: [User.t()] | ||||
|   def list_users_by_role(role) do | ||||
|     role = role |> to_string() | ||||
|     Repo.all(from u in User, where: u.role == ^role, order_by: u.email) | ||||
|   end | ||||
|  | ||||
|   ## User registration | ||||
|  | ||||
|   @doc """ | ||||
|   Registers a user. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> register_user(%{field: value}) | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> register_user(%{field: bad_value}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec register_user(map()) :: {:ok, User.t()} | {:error, Changeset.t(User.new_user())} | ||||
|   def register_user(attrs) do | ||||
|     # if no registered users, make first user an admin | ||||
|     role = | ||||
|       if Repo.one!(from u in User, select: count(u.id), distinct: true) == 0, | ||||
|         do: "admin", | ||||
|         else: "user" | ||||
|  | ||||
|     %User{} |> User.registration_changeset(attrs |> Map.put("role", role)) |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for tracking user changes. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_registration(user) | ||||
|       %Changeset{data: %User{}} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_registration(User.t() | User.new_user()) :: | ||||
|           Changeset.t(User.t() | User.new_user()) | ||||
|   @spec change_user_registration(User.t() | User.new_user(), map()) :: | ||||
|           Changeset.t(User.t() | User.new_user()) | ||||
|   def change_user_registration(user, attrs \\ %{}), | ||||
|     do: User.registration_changeset(user, attrs, hash_password: false) | ||||
|  | ||||
|   ## Settings | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for changing the user email. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_email(user) | ||||
|       %Changeset{data: %User{}} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_email(User.t(), map()) :: Changeset.t(User.t()) | ||||
|   def change_user_email(user, attrs \\ %{}), do: User.email_changeset(user, attrs) | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for changing the user role. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_role(user) | ||||
|       %Changeset{data: %User{}} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_role(User.t(), atom()) :: Changeset.t(User.t()) | ||||
|   def change_user_role(user, role), do: User.role_changeset(user, role) | ||||
|  | ||||
|   @doc """ | ||||
|   Emulates that the email will change without actually changing | ||||
|   it in the database. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> apply_user_email(user, "valid password", %{email: ...}) | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> apply_user_email(user, "invalid password", %{email: ...}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec apply_user_email(User.t(), String.t(), map()) :: | ||||
|           {:ok, User.t()} | {:error, Changeset.t(User.t())} | ||||
|   def apply_user_email(user, password, attrs) do | ||||
|     user | ||||
|     |> User.email_changeset(attrs) | ||||
|     |> User.validate_current_password(password) | ||||
|     |> Changeset.apply_action(:update) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates the user email using the given token. | ||||
|  | ||||
|   If the token matches, the user email is updated and the token is deleted. | ||||
|   The confirmed_at date is also updated to the current time. | ||||
|   """ | ||||
|   @spec update_user_email(User.t(), String.t()) :: :ok | :error | ||||
|   def update_user_email(user, token) do | ||||
|     context = "change:#{user.email}" | ||||
|  | ||||
|     with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), | ||||
|          %UserToken{sent_to: email} <- Repo.one(query), | ||||
|          {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do | ||||
|       :ok | ||||
|     else | ||||
|       _ -> :error | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @spec user_email_multi(User.t(), String.t(), String.t()) :: Multi.t() | ||||
|   defp user_email_multi(user, email, context) do | ||||
|     changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset() | ||||
|  | ||||
|     Multi.new() | ||||
|     |> Multi.update(:user, changeset) | ||||
|     |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Delivers the update email instructions to the given user. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1)) | ||||
|       {:ok, %{to: ..., body: ...}} | ||||
|  | ||||
|   """ | ||||
|   @spec deliver_update_email_instructions(User.t(), String.t(), function) :: Job.t() | ||||
|   def deliver_update_email_instructions(user, current_email, update_email_url_fun) | ||||
|       when is_function(update_email_url_fun, 1) do | ||||
|     {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") | ||||
|     Repo.insert!(user_token) | ||||
|     Mailer.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for changing the user password. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_password(user) | ||||
|       %Changeset{data: %User{}} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_password(User.t(), map()) :: Changeset.t(User.t()) | ||||
|   def change_user_password(user, attrs \\ %{}), | ||||
|     do: User.password_changeset(user, attrs, hash_password: false) | ||||
|  | ||||
|   @doc """ | ||||
|   Updates the user password. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_user_password(user, "valid password", %{password: ...}) | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> update_user_password(user, "invalid password", %{password: ...}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_user_password(User.t(), String.t(), map()) :: | ||||
|           {:ok, User.t()} | {:error, Changeset.t(User.t())} | ||||
|   def update_user_password(user, password, attrs) do | ||||
|     changeset = | ||||
|       user | ||||
|       |> User.password_changeset(attrs) | ||||
|       |> User.validate_current_password(password) | ||||
|  | ||||
|     Multi.new() | ||||
|     |> Multi.update(:user, changeset) | ||||
|     |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{user: user}} -> {:ok, user} | ||||
|       {:error, :user, changeset, _} -> {:error, changeset} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for changing the user locale. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_locale(user) | ||||
|       %Changeset{data: %User{}} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_locale(User.t()) :: Changeset.t(User.t()) | ||||
|   def change_user_locale(%{locale: locale} = user), do: User.locale_changeset(user, locale) | ||||
|  | ||||
|   @doc """ | ||||
|   Updates the user locale. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_user_locale(user, "valid locale") | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> update_user_password(user, "invalid locale") | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_user_locale(User.t(), locale :: String.t()) :: | ||||
|           {:ok, User.t()} | {:error, Changeset.t(User.t())} | ||||
|   def update_user_locale(user, locale), | ||||
|     do: user |> User.locale_changeset(locale) |> Repo.update() | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a user. must be performed by an admin or the same user! | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_user!(user_to_delete, %User{id: 123, role: :admin}) | ||||
|       %User{} | ||||
|  | ||||
|       iex> delete_user!(%User{id: 123}, %User{id: 123}) | ||||
|       %User{} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_user!(User.t(), User.t()) :: User.t() | ||||
|   def delete_user!(user, %User{role: :admin}), do: user |> Repo.delete!() | ||||
|   def delete_user!(%User{id: user_id} = user, %User{id: user_id}), do: user |> Repo.delete!() | ||||
|  | ||||
|   ## Session | ||||
|  | ||||
|   @doc """ | ||||
|   Generates a session token. | ||||
|   """ | ||||
|   @spec generate_user_session_token(User.t()) :: String.t() | ||||
|   def generate_user_session_token(user) do | ||||
|     {token, user_token} = UserToken.build_session_token(user) | ||||
|     Repo.insert!(user_token) | ||||
|     token | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets the user with the given signed token. | ||||
|   """ | ||||
|   @spec get_user_by_session_token(String.t()) :: User.t() | ||||
|   def get_user_by_session_token(token) do | ||||
|     {:ok, query} = UserToken.verify_session_token_query(token) | ||||
|     Repo.one(query) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes the signed token with the given context. | ||||
|   """ | ||||
|   @spec delete_session_token(String.t()) :: :ok | ||||
|   def delete_session_token(token) do | ||||
|     Repo.delete_all(UserToken.token_and_context_query(token, "session")) | ||||
|     :ok | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns a boolean if registration is allowed or not | ||||
|   """ | ||||
|   @spec allow_registration?() :: boolean() | ||||
|   def allow_registration? do | ||||
|     Application.get_env(:memex, MemexWeb.Endpoint)[:registration] == "public" or | ||||
|       list_users_by_role(:admin) |> Enum.empty?() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Checks if user is an admin | ||||
|   """ | ||||
|   @spec is_admin?(User.t()) :: boolean() | ||||
|   def is_admin?(%User{id: user_id}) do | ||||
|     Repo.one(from u in User, where: u.id == ^user_id and u.role == :admin) | ||||
|     |> is_nil() | ||||
|   end | ||||
|  | ||||
|   ## Confirmation | ||||
|  | ||||
|   @doc """ | ||||
|   Delivers the confirmation email instructions to the given user. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1)) | ||||
|       {:ok, %{to: ..., body: ...}} | ||||
|  | ||||
|       iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1)) | ||||
|       {:error, :already_confirmed} | ||||
|  | ||||
|   """ | ||||
|   @spec deliver_user_confirmation_instructions(User.t(), function) :: Job.t() | ||||
|   def deliver_user_confirmation_instructions(user, confirmation_url_fun) | ||||
|       when is_function(confirmation_url_fun, 1) do | ||||
|     if user.confirmed_at do | ||||
|       {:error, :already_confirmed} | ||||
|     else | ||||
|       {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") | ||||
|       Repo.insert!(user_token) | ||||
|       Mailer.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Confirms a user by the given token. | ||||
|  | ||||
|   If the token matches, the user account is marked as confirmed | ||||
|   and the token is deleted. | ||||
|   """ | ||||
|   @spec confirm_user(String.t()) :: {:ok, User.t()} | atom() | ||||
|   def confirm_user(token) do | ||||
|     with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), | ||||
|          %User{} = user <- Repo.one(query), | ||||
|          {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do | ||||
|       {:ok, user} | ||||
|     else | ||||
|       _ -> :error | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @spec confirm_user_multi(User.t()) :: Multi.t() | ||||
|   def confirm_user_multi(user) do | ||||
|     Multi.new() | ||||
|     |> Multi.update(:user, User.confirm_changeset(user)) | ||||
|     |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) | ||||
|   end | ||||
|  | ||||
|   ## Reset password | ||||
|  | ||||
|   @doc """ | ||||
|   Delivers the reset password email to the given user. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1)) | ||||
|       {:ok, %{to: ..., body: ...}} | ||||
|  | ||||
|   """ | ||||
|   @spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t() | ||||
|   def deliver_user_reset_password_instructions(user, reset_password_url_fun) | ||||
|       when is_function(reset_password_url_fun, 1) do | ||||
|     {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") | ||||
|     Repo.insert!(user_token) | ||||
|     Mailer.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets the user by reset password token. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_user_by_reset_password_token("validtoken") | ||||
|       %User{} | ||||
|  | ||||
|       iex> get_user_by_reset_password_token("invalidtoken") | ||||
|       nil | ||||
|  | ||||
|   """ | ||||
|   @spec get_user_by_reset_password_token(String.t()) :: User.t() | nil | ||||
|   def get_user_by_reset_password_token(token) do | ||||
|     with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), | ||||
|          %User{} = user <- Repo.one(query) do | ||||
|       user | ||||
|     else | ||||
|       _ -> nil | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Resets the user password. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec reset_user_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t(User.t())} | ||||
|   def reset_user_password(user, attrs) do | ||||
|     Multi.new() | ||||
|     |> Multi.update(:user, User.password_changeset(user, attrs)) | ||||
|     |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{user: user}} -> {:ok, user} | ||||
|       {:error, :user, changeset, _} -> {:error, changeset} | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										48
									
								
								lib/memex/accounts/email.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								lib/memex/accounts/email.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| defmodule Memex.Email do | ||||
|   @moduledoc """ | ||||
|   Emails that can be sent using Swoosh. | ||||
|  | ||||
|   You can find the base email templates at | ||||
|   `lib/memex_web/templates/layout/email.html.heex` for html emails and | ||||
|   `lib/memex_web/templates/layout/email.txt.heex` for text emails. | ||||
|   """ | ||||
|  | ||||
|   use Phoenix.Swoosh, view: MemexWeb.EmailView, layout: {MemexWeb.LayoutView, :email} | ||||
|   import MemexWeb.Gettext | ||||
|   alias Memex.Accounts.User | ||||
|   alias MemexWeb.EmailView | ||||
|  | ||||
|   @typedoc """ | ||||
|   Represents an HTML and text body email that can be sent | ||||
|   """ | ||||
|   @type t() :: Swoosh.Email.t() | ||||
|  | ||||
|   @spec base_email(User.t(), String.t()) :: t() | ||||
|   defp base_email(%User{email: email}, subject) do | ||||
|     from = Application.get_env(:Memex, Memex.Mailer)[:email_from] || "noreply@localhost" | ||||
|     name = Application.get_env(:Memex, Memex.Mailer)[:email_name] | ||||
|     new() |> to(email) |> from({name, from}) |> subject(subject) | ||||
|   end | ||||
|  | ||||
|   @spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t() | ||||
|   def generate_email("welcome", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Confirm your Memex account")) | ||||
|     |> render_body("confirm_email.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url})) | ||||
|   end | ||||
|  | ||||
|   def generate_email("reset_password", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Reset your Memex password")) | ||||
|     |> render_body("reset_password.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("reset_password.txt", %{user: user, url: url})) | ||||
|   end | ||||
|  | ||||
|   def generate_email("update_email", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Update your Memex email")) | ||||
|     |> render_body("update_email.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("update_email.txt", %{user: user, url: url})) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										13
									
								
								lib/memex/accounts/email_worker.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								lib/memex/accounts/email_worker.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| defmodule Memex.EmailWorker do | ||||
|   @moduledoc """ | ||||
|   Oban worker that dispatches emails | ||||
|   """ | ||||
|  | ||||
|   use Oban.Worker, queue: :mailers, tags: ["email"] | ||||
|   alias Memex.{Accounts, Email, Mailer} | ||||
|  | ||||
|   @impl Oban.Worker | ||||
|   def perform(%Oban.Job{args: %{"email" => email, "user_id" => user_id, "attrs" => attrs}}) do | ||||
|     Email.generate_email(email, user_id |> Accounts.get_user!(), attrs) |> Mailer.deliver() | ||||
|   end | ||||
| end | ||||
							
								
								
									
										200
									
								
								lib/memex/accounts/user.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								lib/memex/accounts/user.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,200 @@ | ||||
| defmodule Memex.Accounts.User do | ||||
|   @moduledoc """ | ||||
|   A Memex user | ||||
|   """ | ||||
|  | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   import MemexWeb.Gettext | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Memex.{Accounts.User, Invites.Invite} | ||||
|  | ||||
|   @derive {Inspect, except: [:password]} | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "users" do | ||||
|     field :email, :string | ||||
|     field :password, :string, virtual: true | ||||
|     field :hashed_password, :string | ||||
|     field :confirmed_at, :naive_datetime | ||||
|     field :role, Ecto.Enum, values: [:admin, :user], default: :user | ||||
|     field :locale, :string | ||||
|  | ||||
|     has_many :invites, Invite, on_delete: :delete_all | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @type t :: %User{ | ||||
|           id: id(), | ||||
|           email: String.t(), | ||||
|           password: String.t(), | ||||
|           hashed_password: String.t(), | ||||
|           confirmed_at: NaiveDateTime.t(), | ||||
|           role: atom(), | ||||
|           invites: [Invite.t()], | ||||
|           locale: String.t() | nil, | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_user :: %User{} | ||||
|   @type id :: UUID.t() | ||||
|  | ||||
|   @doc """ | ||||
|   A user changeset for registration. | ||||
|  | ||||
|   It is important to validate the length of both email and password. | ||||
|   Otherwise databases may truncate the email without warnings, which | ||||
|   could lead to unpredictable or insecure behaviour. Long passwords may | ||||
|   also be very expensive to hash for certain algorithms. | ||||
|  | ||||
|   ## Options | ||||
|  | ||||
|     * `:hash_password` - Hashes the password so it can be stored securely | ||||
|       in the database and ensures the password field is cleared to prevent | ||||
|       leaks in the logs. If password hashing is not needed and clearing the | ||||
|       password field is not desired (like when using this changeset for | ||||
|       validations on a LiveView form), this option can be set to `false`. | ||||
|       Defaults to `true`. | ||||
|   """ | ||||
|   @spec registration_changeset(t() | new_user(), attrs :: map()) :: Changeset.t(t() | new_user()) | ||||
|   @spec registration_changeset(t() | new_user(), attrs :: map(), opts :: keyword()) :: | ||||
|           Changeset.t(t() | new_user()) | ||||
|   def registration_changeset(user, attrs, opts \\ []) do | ||||
|     user | ||||
|     |> cast(attrs, [:email, :password, :role, :locale]) | ||||
|     |> validate_email() | ||||
|     |> validate_password(opts) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   A user changeset for role. | ||||
|  | ||||
|   """ | ||||
|   @spec role_changeset(t(), role :: atom()) :: Changeset.t(t()) | ||||
|   def role_changeset(user, role) do | ||||
|     user |> cast(%{"role" => role}, [:role]) | ||||
|   end | ||||
|  | ||||
|   @spec validate_email(Changeset.t(t() | new_user())) :: Changeset.t(t() | new_user()) | ||||
|   defp validate_email(changeset) do | ||||
|     changeset | ||||
|     |> validate_required([:email]) | ||||
|     |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, | ||||
|       message: dgettext("errors", "must have the @ sign and no spaces") | ||||
|     ) | ||||
|     |> validate_length(:email, max: 160) | ||||
|     |> unsafe_validate_unique(:email, Memex.Repo) | ||||
|     |> unique_constraint(:email) | ||||
|   end | ||||
|  | ||||
|   @spec validate_password(Changeset.t(t() | new_user()), opts :: keyword()) :: | ||||
|           Changeset.t(t() | new_user()) | ||||
|   defp validate_password(changeset, opts) do | ||||
|     changeset | ||||
|     |> validate_required([:password]) | ||||
|     |> validate_length(:password, min: 12, max: 80) | ||||
|     # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") | ||||
|     # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") | ||||
|     # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") | ||||
|     |> maybe_hash_password(opts) | ||||
|   end | ||||
|  | ||||
|   @spec maybe_hash_password(Changeset.t(t() | new_user()), opts :: keyword()) :: | ||||
|           Changeset.t(t() | new_user()) | ||||
|   defp maybe_hash_password(changeset, opts) do | ||||
|     hash_password? = Keyword.get(opts, :hash_password, true) | ||||
|     password = get_change(changeset, :password) | ||||
|  | ||||
|     if hash_password? && password && changeset.valid? do | ||||
|       changeset | ||||
|       |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) | ||||
|       |> delete_change(:password) | ||||
|     else | ||||
|       changeset | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   A user changeset for changing the email. | ||||
|  | ||||
|   It requires the email to change otherwise an error is added. | ||||
|   """ | ||||
|   @spec email_changeset(t(), attrs :: map()) :: Changeset.t(t()) | ||||
|   def email_changeset(user, attrs) do | ||||
|     user | ||||
|     |> cast(attrs, [:email]) | ||||
|     |> validate_email() | ||||
|     |> case do | ||||
|       %{changes: %{email: _}} = changeset -> changeset | ||||
|       %{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change")) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   A user changeset for changing the password. | ||||
|  | ||||
|   ## Options | ||||
|  | ||||
|     * `:hash_password` - Hashes the password so it can be stored securely | ||||
|       in the database and ensures the password field is cleared to prevent | ||||
|       leaks in the logs. If password hashing is not needed and clearing the | ||||
|       password field is not desired (like when using this changeset for | ||||
|       validations on a LiveView form), this option can be set to `false`. | ||||
|       Defaults to `true`. | ||||
|   """ | ||||
|   @spec password_changeset(t(), attrs :: map()) :: Changeset.t(t()) | ||||
|   @spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: Changeset.t(t()) | ||||
|   def password_changeset(user, attrs, opts \\ []) do | ||||
|     user | ||||
|     |> cast(attrs, [:password]) | ||||
|     |> validate_confirmation(:password, message: dgettext("errors", "does not match password")) | ||||
|     |> validate_password(opts) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Confirms the account by setting `confirmed_at`. | ||||
|   """ | ||||
|   @spec confirm_changeset(t() | Changeset.t(t())) :: Changeset.t(t()) | ||||
|   def confirm_changeset(user_or_changeset) do | ||||
|     now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) | ||||
|     user_or_changeset |> change(confirmed_at: now) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Verifies the password. | ||||
|  | ||||
|   If there is no user or the user doesn't have a password, we call | ||||
|   `Bcrypt.no_user_verify/0` to avoid timing attacks. | ||||
|   """ | ||||
|   @spec valid_password?(t(), String.t()) :: boolean() | ||||
|   def valid_password?(%User{hashed_password: hashed_password}, password) | ||||
|       when is_binary(hashed_password) and byte_size(password) > 0 do | ||||
|     Bcrypt.verify_pass(password, hashed_password) | ||||
|   end | ||||
|  | ||||
|   def valid_password?(_, _) do | ||||
|     Bcrypt.no_user_verify() | ||||
|     false | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Validates the current password otherwise adds an error to the changeset. | ||||
|   """ | ||||
|   @spec validate_current_password(Changeset.t(t()), String.t()) :: Changeset.t(t()) | ||||
|   def validate_current_password(changeset, password) do | ||||
|     if valid_password?(changeset.data, password), | ||||
|       do: changeset, | ||||
|       else: changeset |> add_error(:current_password, dgettext("errors", "is not valid")) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   A changeset for changing the user's locale | ||||
|   """ | ||||
|   @spec locale_changeset(t() | Changeset.t(t()), locale :: String.t() | nil) :: Changeset.t(t()) | ||||
|   def locale_changeset(user_or_changeset, locale) do | ||||
|     user_or_changeset | ||||
|     |> cast(%{"locale" => locale}, [:locale]) | ||||
|     |> validate_required(:locale) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										77
									
								
								lib/memex/accounts/user_notifier.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								lib/memex/accounts/user_notifier.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| defmodule Memex.Accounts.UserNotifier do | ||||
|   @moduledoc """ | ||||
|   Contains templates and messages for user messages | ||||
|   """ | ||||
|  | ||||
|   # For simplicity, this module simply logs messages to the terminal. | ||||
|   # You should replace it by a proper email or notification tool, such as: | ||||
|   # | ||||
|   #   * Swoosh - https://hexdocs.pm/swoosh | ||||
|   #   * Bamboo - https://hexdocs.pm/bamboo | ||||
|   # | ||||
|   defp deliver(to, body) do | ||||
|     require Logger | ||||
|     Logger.debug(body) | ||||
|     {:ok, %{to: to, body: body}} | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deliver instructions to confirm account. | ||||
|   """ | ||||
|   def deliver_confirmation_instructions(user, url) do | ||||
|     deliver(user.email, """ | ||||
|  | ||||
|     ============================== | ||||
|  | ||||
|     Hi #{user.email}, | ||||
|  | ||||
|     You can confirm your account by visiting the URL below: | ||||
|  | ||||
|     #{url} | ||||
|  | ||||
|     If you didn't create an account with us, please ignore this. | ||||
|  | ||||
|     ============================== | ||||
|     """) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deliver instructions to reset a user password. | ||||
|   """ | ||||
|   def deliver_reset_password_instructions(user, url) do | ||||
|     deliver(user.email, """ | ||||
|  | ||||
|     ============================== | ||||
|  | ||||
|     Hi #{user.email}, | ||||
|  | ||||
|     You can reset your password by visiting the URL below: | ||||
|  | ||||
|     #{url} | ||||
|  | ||||
|     If you didn't request this change, please ignore this. | ||||
|  | ||||
|     ============================== | ||||
|     """) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deliver instructions to update a user email. | ||||
|   """ | ||||
|   def deliver_update_email_instructions(user, url) do | ||||
|     deliver(user.email, """ | ||||
|  | ||||
|     ============================== | ||||
|  | ||||
|     Hi #{user.email}, | ||||
|  | ||||
|     You can change your email by visiting the URL below: | ||||
|  | ||||
|     #{url} | ||||
|  | ||||
|     If you didn't request this change, please ignore this. | ||||
|  | ||||
|     ============================== | ||||
|     """) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										145
									
								
								lib/memex/accounts/user_token.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								lib/memex/accounts/user_token.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,145 @@ | ||||
| defmodule Memex.Accounts.UserToken do | ||||
|   @moduledoc """ | ||||
|   Schema for a user's session token | ||||
|   """ | ||||
|  | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Query | ||||
|  | ||||
|   @hash_algorithm :sha256 | ||||
|   @rand_size 32 | ||||
|  | ||||
|   # It is very important to keep the reset password token expiry short, | ||||
|   # since someone with access to the email may take over the account. | ||||
|   @reset_password_validity_in_days 1 | ||||
|   @confirm_validity_in_days 7 | ||||
|   @change_email_validity_in_days 7 | ||||
|   @session_validity_in_days 60 | ||||
|  | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "users_tokens" do | ||||
|     field :token, :binary | ||||
|     field :context, :string | ||||
|     field :sent_to, :string | ||||
|     belongs_to :user, Memex.Accounts.User | ||||
|  | ||||
|     timestamps(updated_at: false) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Generates a token that will be stored in a signed place, | ||||
|   such as session or cookie. As they are signed, those | ||||
|   tokens do not need to be hashed. | ||||
|   """ | ||||
|   def build_session_token(user) do | ||||
|     token = :crypto.strong_rand_bytes(@rand_size) | ||||
|     {token, %Memex.Accounts.UserToken{token: token, context: "session", user_id: user.id}} | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Checks if the token is valid and returns its underlying lookup query. | ||||
|  | ||||
|   The query returns the user found by the token. | ||||
|   """ | ||||
|   def verify_session_token_query(token) do | ||||
|     query = | ||||
|       from token in token_and_context_query(token, "session"), | ||||
|         join: user in assoc(token, :user), | ||||
|         where: token.inserted_at > ago(@session_validity_in_days, "day"), | ||||
|         select: user | ||||
|  | ||||
|     {:ok, query} | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Builds a token with a hashed counter part. | ||||
|  | ||||
|   The non-hashed token is sent to the user email while the | ||||
|   hashed part is stored in the database, to avoid reconstruction. | ||||
|   The token is valid for a week as long as users don't change | ||||
|   their email. | ||||
|   """ | ||||
|   def build_email_token(user, context) do | ||||
|     build_hashed_token(user, context, user.email) | ||||
|   end | ||||
|  | ||||
|   defp build_hashed_token(user, context, sent_to) do | ||||
|     token = :crypto.strong_rand_bytes(@rand_size) | ||||
|     hashed_token = :crypto.hash(@hash_algorithm, token) | ||||
|  | ||||
|     {Base.url_encode64(token, padding: false), | ||||
|      %Memex.Accounts.UserToken{ | ||||
|        token: hashed_token, | ||||
|        context: context, | ||||
|        sent_to: sent_to, | ||||
|        user_id: user.id | ||||
|      }} | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Checks if the token is valid and returns its underlying lookup query. | ||||
|  | ||||
|   The query returns the user found by the token. | ||||
|   """ | ||||
|   def verify_email_token_query(token, context) do | ||||
|     case Base.url_decode64(token, padding: false) do | ||||
|       {:ok, decoded_token} -> | ||||
|         hashed_token = :crypto.hash(@hash_algorithm, decoded_token) | ||||
|         days = days_for_context(context) | ||||
|  | ||||
|         query = | ||||
|           from token in token_and_context_query(hashed_token, context), | ||||
|             join: user in assoc(token, :user), | ||||
|             where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, | ||||
|             select: user | ||||
|  | ||||
|         {:ok, query} | ||||
|  | ||||
|       :error -> | ||||
|         :error | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp days_for_context("confirm"), do: @confirm_validity_in_days | ||||
|   defp days_for_context("reset_password"), do: @reset_password_validity_in_days | ||||
|  | ||||
|   @doc """ | ||||
|   Checks if the token is valid and returns its underlying lookup query. | ||||
|  | ||||
|   The query returns the user token record. | ||||
|   """ | ||||
|   def verify_change_email_token_query(token, context) do | ||||
|     case Base.url_decode64(token, padding: false) do | ||||
|       {:ok, decoded_token} -> | ||||
|         hashed_token = :crypto.hash(@hash_algorithm, decoded_token) | ||||
|  | ||||
|         query = | ||||
|           from token in token_and_context_query(hashed_token, context), | ||||
|             where: token.inserted_at > ago(@change_email_validity_in_days, "day") | ||||
|  | ||||
|         {:ok, query} | ||||
|  | ||||
|       :error -> | ||||
|         :error | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the given token with the given context. | ||||
|   """ | ||||
|   def token_and_context_query(token, context) do | ||||
|     from Memex.Accounts.UserToken, where: [token: ^token, context: ^context] | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets all tokens for the given user for the given contexts. | ||||
|   """ | ||||
|   def user_and_contexts_query(user, :all) do | ||||
|     from t in Memex.Accounts.UserToken, where: t.user_id == ^user.id | ||||
|   end | ||||
|  | ||||
|   def user_and_contexts_query(user, [_ | _] = contexts) do | ||||
|     from t in Memex.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts | ||||
|   end | ||||
| end | ||||
							
								
								
									
										48
									
								
								lib/memex/application.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								lib/memex/application.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| defmodule Memex.Application do | ||||
|   # See https://hexdocs.pm/elixir/Application.html | ||||
|   # for more information on OTP Applications | ||||
|   @moduledoc false | ||||
|  | ||||
|   use Application | ||||
|  | ||||
|   @impl true | ||||
|   def start(_type, _args) do | ||||
|     children = [ | ||||
|       # Start the Ecto repository | ||||
|       Memex.Repo, | ||||
|       # Start the Telemetry supervisor | ||||
|       MemexWeb.Telemetry, | ||||
|       # Start the PubSub system | ||||
|       {Phoenix.PubSub, name: Memex.PubSub}, | ||||
|       # Start the Endpoint (http/https) | ||||
|       MemexWeb.Endpoint, | ||||
|       # Add Oban | ||||
|       {Oban, oban_config()} | ||||
|       # Start a worker by calling: Memex.Worker.start_link(arg) | ||||
|       # {Memex.Worker, arg} | ||||
|     ] | ||||
|  | ||||
|     # Automatically migrate on start in prod | ||||
|     children = | ||||
|       if Application.get_env(:memex, Memex.Application, automigrate: false)[:automigrate], | ||||
|         do: children ++ [Memex.Repo.Migrator], | ||||
|         else: children | ||||
|  | ||||
|     # See https://hexdocs.pm/elixir/Supervisor.html | ||||
|     # for other strategies and supported options | ||||
|     opts = [strategy: :one_for_one, name: Memex.Supervisor] | ||||
|     Supervisor.start_link(children, opts) | ||||
|   end | ||||
|  | ||||
|   # Tell Phoenix to update the endpoint configuration | ||||
|   # whenever the application is updated. | ||||
|   @impl true | ||||
|   def config_change(changed, _new, removed) do | ||||
|     MemexWeb.Endpoint.config_change(changed, removed) | ||||
|     :ok | ||||
|   end | ||||
|  | ||||
|   defp oban_config do | ||||
|     Application.fetch_env!(:memex, Oban) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										173
									
								
								lib/memex/invites.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								lib/memex/invites.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,173 @@ | ||||
| defmodule Memex.Invites do | ||||
|   @moduledoc """ | ||||
|   The Invites context. | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Ecto.Changeset | ||||
|   alias Memex.{Accounts.User, Invites.Invite, Repo} | ||||
|  | ||||
|   @invite_token_length 20 | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of invites. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_invites(%User{id: 123, role: :admin}) | ||||
|       [%Invite{}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_invites(User.t()) :: [Invite.t()] | ||||
|   def list_invites(%User{role: :admin}) do | ||||
|     Repo.all(from i in Invite, order_by: i.name) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single invite. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Invite does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_invite!(123, %User{id: 123, role: :admin}) | ||||
|       %Invite{} | ||||
|  | ||||
|       iex> get_invite!(456, %User{id: 123, role: :admin}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_invite!(Invite.id(), User.t()) :: Invite.t() | ||||
|   def get_invite!(id, %User{role: :admin}) do | ||||
|     Repo.get!(Invite, id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns a valid invite or nil based on the attempted token | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_invite_by_token("valid_token") | ||||
|       %Invite{} | ||||
|  | ||||
|       iex> get_invite_by_token("invalid_token") | ||||
|       nil | ||||
|   """ | ||||
|   @spec get_invite_by_token(token :: String.t() | nil) :: Invite.t() | nil | ||||
|   def get_invite_by_token(nil), do: nil | ||||
|   def get_invite_by_token(""), do: nil | ||||
|  | ||||
|   def get_invite_by_token(token) do | ||||
|     Repo.one( | ||||
|       from(i in Invite, | ||||
|         where: i.token == ^token and i.disabled_at |> is_nil() | ||||
|       ) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Uses invite by decrementing uses_left, or marks invite invalid if it's been | ||||
|   completely used. | ||||
|   """ | ||||
|   @spec use_invite!(Invite.t()) :: Invite.t() | ||||
|   def use_invite!(%Invite{uses_left: nil} = invite), do: invite | ||||
|  | ||||
|   def use_invite!(%Invite{uses_left: uses_left} = invite) do | ||||
|     new_uses_left = uses_left - 1 | ||||
|  | ||||
|     attrs = | ||||
|       if new_uses_left <= 0 do | ||||
|         now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) | ||||
|         %{"uses_left" => 0, "disabled_at" => now} | ||||
|       else | ||||
|         %{"uses_left" => new_uses_left} | ||||
|       end | ||||
|  | ||||
|     invite |> Invite.update_changeset(attrs) |> Repo.update!() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: value}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec create_invite(User.t(), attrs :: map()) :: | ||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.new_invite())} | ||||
|   def create_invite(%User{id: user_id, role: :admin}, attrs) do | ||||
|     token = | ||||
|       :crypto.strong_rand_bytes(@invite_token_length) | ||||
|       |> Base.url_encode64() | ||||
|       |> binary_part(0, @invite_token_length) | ||||
|  | ||||
|     attrs = attrs |> Map.merge(%{"user_id" => user_id, "token" => token}) | ||||
|  | ||||
|     %Invite{} |> Invite.create_changeset(attrs) |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_invite(Invite.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.t())} | ||||
|   def update_invite(invite, attrs, %User{role: :admin}), | ||||
|     do: invite |> Invite.update_changeset(attrs) |> Repo.update() | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_invite(Invite.t(), User.t()) :: | ||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.t())} | ||||
|   def delete_invite(invite, %User{role: :admin}), do: invite |> Repo.delete() | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       %Invite{} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_invite!(Invite.t(), User.t()) :: Invite.t() | ||||
|   def delete_invite!(invite, %User{role: :admin}), do: invite |> Repo.delete!() | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for tracking invite changes. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_invite(invite) | ||||
|       %Changeset{data: %Invite{}} | ||||
|  | ||||
|   """ | ||||
|   @spec change_invite(Invite.t() | Invite.new_invite()) :: | ||||
|           Changeset.t(Invite.t() | Invite.new_invite()) | ||||
|   @spec change_invite(Invite.t() | Invite.new_invite(), attrs :: map()) :: | ||||
|           Changeset.t(Invite.t() | Invite.new_invite()) | ||||
|   def change_invite(invite, attrs \\ %{}), do: invite |> Invite.update_changeset(attrs) | ||||
| end | ||||
							
								
								
									
										57
									
								
								lib/memex/invites/invite.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								lib/memex/invites/invite.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| defmodule Memex.Invites.Invite do | ||||
|   @moduledoc """ | ||||
|   An invite, created by an admin to allow someone to join their instance. An | ||||
|   invite can be enabled or disabled, and can have an optional number of uses if | ||||
|   `:uses_left` is defined. | ||||
|   """ | ||||
|  | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Memex.{Accounts.User, Invites.Invite} | ||||
|  | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "invites" do | ||||
|     field :name, :string | ||||
|     field :token, :string | ||||
|     field :uses_left, :integer, default: nil | ||||
|     field :disabled_at, :naive_datetime | ||||
|  | ||||
|     belongs_to :user, User | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @type t :: %Invite{ | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|           token: String.t(), | ||||
|           uses_left: integer() | nil, | ||||
|           disabled_at: NaiveDateTime.t(), | ||||
|           user: User.t(), | ||||
|           user_id: User.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_invite :: %Invite{} | ||||
|   @type id :: UUID.t() | ||||
|  | ||||
|   @doc false | ||||
|   @spec create_changeset(new_invite(), attrs :: map()) :: Changeset.t(new_invite()) | ||||
|   def create_changeset(invite, attrs) do | ||||
|     invite | ||||
|     |> cast(attrs, [:name, :token, :uses_left, :disabled_at, :user_id]) | ||||
|     |> validate_required([:name, :token, :user_id]) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_invite(), attrs :: map()) :: Changeset.t(t() | new_invite()) | ||||
|   def update_changeset(invite, attrs) do | ||||
|     invite | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
|     |> validate_required([:name, :token, :user_id]) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										42
									
								
								lib/memex/mailer.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								lib/memex/mailer.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| defmodule Memex.Mailer do | ||||
|   @moduledoc """ | ||||
|   Mailer adapter for emails | ||||
|  | ||||
|   Since emails are loaded as Oban jobs, the `:attrs` map must be serializable to | ||||
|   json with Jason, which restricts the use of structs. | ||||
|   """ | ||||
|  | ||||
|   use Swoosh.Mailer, otp_app: :memex | ||||
|   alias Memex.{Accounts.User, EmailWorker} | ||||
|   alias Oban.Job | ||||
|  | ||||
|   @doc """ | ||||
|   Deliver instructions to confirm account. | ||||
|   """ | ||||
|   @spec deliver_confirmation_instructions(User.t(), String.t()) :: Job.t() | ||||
|   def deliver_confirmation_instructions(%User{id: user_id}, url) do | ||||
|     %{email: :welcome, user_id: user_id, attrs: %{url: url}} | ||||
|     |> EmailWorker.new() | ||||
|     |> Oban.insert!() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deliver instructions to reset a user password. | ||||
|   """ | ||||
|   @spec deliver_reset_password_instructions(User.t(), String.t()) :: Job.t() | ||||
|   def deliver_reset_password_instructions(%User{id: user_id}, url) do | ||||
|     %{email: :reset_password, user_id: user_id, attrs: %{url: url}} | ||||
|     |> EmailWorker.new() | ||||
|     |> Oban.insert!() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deliver instructions to update a user email. | ||||
|   """ | ||||
|   @spec deliver_update_email_instructions(User.t(), String.t()) :: Job.t() | ||||
|   def deliver_update_email_instructions(%User{id: user_id}, url) do | ||||
|     %{email: :update_email, user_id: user_id, attrs: %{url: url}} | ||||
|     |> EmailWorker.new() | ||||
|     |> Oban.insert!() | ||||
|   end | ||||
| end | ||||
							
								
								
									
										24
									
								
								lib/memex/release.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								lib/memex/release.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| defmodule Memex.Release do | ||||
|   @moduledoc """ | ||||
|   Contains mix tasks that can used in generated releases | ||||
|   """ | ||||
|  | ||||
|   @app :memex | ||||
|  | ||||
|   def rollback(repo, version) do | ||||
|     load_app() | ||||
|     {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) | ||||
|   end | ||||
|  | ||||
|   defp load_app do | ||||
|     Application.load(@app) | ||||
|   end | ||||
|  | ||||
|   def migrate do | ||||
|     load_app() | ||||
|  | ||||
|     for repo <- Application.fetch_env!(@app, :ecto_repos) do | ||||
|       {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										5
									
								
								lib/memex/repo.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								lib/memex/repo.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| defmodule Memex.Repo do | ||||
|   use Ecto.Repo, | ||||
|     otp_app: :memex, | ||||
|     adapter: Ecto.Adapters.Postgres | ||||
| end | ||||
							
								
								
									
										22
									
								
								lib/memex/repo/migrator.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								lib/memex/repo/migrator.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| defmodule Memex.Repo.Migrator do | ||||
|   @moduledoc """ | ||||
|   Genserver to automatically perform all migration on app start | ||||
|   """ | ||||
|  | ||||
|   use GenServer | ||||
|   require Logger | ||||
|  | ||||
|   def start_link(_) do | ||||
|     GenServer.start_link(__MODULE__, [], []) | ||||
|   end | ||||
|  | ||||
|   def init(_) do | ||||
|     migrate!() | ||||
|     {:ok, nil} | ||||
|   end | ||||
|  | ||||
|   def migrate! do | ||||
|     path = Application.app_dir(:memex, "priv/repo/migrations") | ||||
|     Ecto.Migrator.run(Memex.Repo, path, :up, all: true) | ||||
|   end | ||||
| end | ||||
		Reference in New Issue
	
	Block a user