rename to memex

This commit is contained in:
2022-07-25 19:31:54 -04:00
parent 65ec4286da
commit 1a423f703b
122 changed files with 416 additions and 416 deletions

479
lib/memex/accounts.ex Normal file
View 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

View 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

View 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
View 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

View 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

View 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
View 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
View 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

View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
defmodule Memex.Repo do
use Ecto.Repo,
otp_app: :memex,
adapter: Ecto.Adapters.Postgres
end

View 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