- CacheTest: fix sweep timing race by using negative TTL (-1) instead of positive TTL (1) for already-expired entries - ScoreCache: replace ETS match-spec DateTime comparisons with :ets.foldl + DateTime.compare — DateTime structs are maps in Elixir >= 1.15 and ETS can't compare maps with :< / :> guards - Accounts: drop unsupported returning: true on delete_all, return [] for expired tokens list - Backtest: catch ArgumentError from String.to_existing_atom for unknown feature names, preserving the helpful Mix.Error - ContactLive IndexTest: invalidate monthly_bars cache before assertion so test data is visible - RoverLocationsLive MapTest: invalidate cached points before assertion - StatusLiveTest: add DB cleanup in setup to reduce test interference; parameterize NARR candidate coordinates
538 lines
16 KiB
Elixir
538 lines
16 KiB
Elixir
defmodule Microwaveprop.Accounts do
|
|
@moduledoc """
|
|
The Accounts context.
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
|
|
alias Microwaveprop.Accounts.User
|
|
alias Microwaveprop.Accounts.UserApiToken
|
|
alias Microwaveprop.Accounts.UserNotifier
|
|
alias Microwaveprop.Accounts.UserToken
|
|
alias Microwaveprop.Repo
|
|
## Database getters
|
|
alias Microwaveprop.Workers.UserHomeQthLookupWorker
|
|
|
|
@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)
|
|
end
|
|
|
|
@doc """
|
|
Gets a user by their amateur callsign. The lookup is case-insensitive
|
|
so `/u/w5isp` and `/u/W5ISP` resolve to the same profile.
|
|
|
|
Returns `nil` for unknown callsigns and for `nil`/empty input.
|
|
|
|
## Examples
|
|
|
|
iex> get_user_by_callsign("W5ISP")
|
|
%User{}
|
|
|
|
iex> get_user_by_callsign("unknown")
|
|
nil
|
|
|
|
"""
|
|
@spec get_user_by_callsign(String.t() | nil) :: User.t() | nil
|
|
def get_user_by_callsign(nil), do: nil
|
|
def get_user_by_callsign(""), do: nil
|
|
|
|
def get_user_by_callsign(callsign) when is_binary(callsign) do
|
|
Repo.get_by(User, callsign: String.upcase(callsign))
|
|
end
|
|
|
|
@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!(Ecto.UUID.t()) :: User.t()
|
|
def get_user!(id), do: Repo.get!(User, id)
|
|
|
|
## Admin user management
|
|
|
|
@doc "Returns users ordered by callsign, limited to 100."
|
|
@spec list_users() :: [User.t()]
|
|
def list_users do
|
|
Repo.all(from u in User, order_by: [asc: u.callsign], limit: 100)
|
|
end
|
|
|
|
@doc "Updates admin-managed user fields (callsign, name, email, is_admin)."
|
|
@spec admin_update_user(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
|
|
def admin_update_user(%User{} = user, attrs) do
|
|
user
|
|
|> User.admin_changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc "Returns an admin-edit changeset for rendering forms."
|
|
@spec change_admin_user(User.t(), map()) :: Ecto.Changeset.t()
|
|
def change_admin_user(%User{} = user, attrs \\ %{}) do
|
|
User.admin_changeset(user, attrs)
|
|
end
|
|
|
|
@doc "Deletes a user."
|
|
@spec delete_user(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
|
|
def delete_user(%User{} = user), do: Repo.delete(user)
|
|
|
|
## User registration
|
|
|
|
@doc """
|
|
Registers a user.
|
|
|
|
## Examples
|
|
|
|
iex> register_user(%{field: value})
|
|
{:ok, %User{}}
|
|
|
|
iex> register_user(%{field: bad_value})
|
|
{:error, %Ecto.Changeset{}}
|
|
|
|
"""
|
|
@spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
|
|
def register_user(attrs) do
|
|
%User{}
|
|
|> User.registration_changeset(attrs)
|
|
|> Repo.insert()
|
|
|> maybe_enqueue_home_qth_lookup()
|
|
end
|
|
|
|
defp maybe_enqueue_home_qth_lookup({:ok, %User{id: id, callsign: call} = user}) when is_binary(call) do
|
|
_ =
|
|
if home_qth_lookup_enabled?() do
|
|
worker = UserHomeQthLookupWorker
|
|
|
|
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
|
|
_ = %{user_id: id} |> worker.new() |> Oban.insert()
|
|
end
|
|
end
|
|
|
|
{:ok, user}
|
|
end
|
|
|
|
defp maybe_enqueue_home_qth_lookup(other), do: other
|
|
|
|
defp home_qth_lookup_enabled? do
|
|
Application.get_env(:microwaveprop, :enable_home_qth_lookup, true)
|
|
end
|
|
|
|
@doc """
|
|
Enqueue a `UserHomeQthLookupWorker` job for every user whose
|
|
`home_grid` is still nil. Safe to call repeatedly — the worker
|
|
no-ops when the user has set their QTH manually.
|
|
"""
|
|
@spec backfill_missing_home_qth() :: :ok
|
|
def backfill_missing_home_qth do
|
|
worker = UserHomeQthLookupWorker
|
|
|
|
_ =
|
|
if home_qth_lookup_enabled?() and Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
|
|
User
|
|
|> where([u], is_nil(u.home_grid))
|
|
|> select([u], u.id)
|
|
|> Repo.all()
|
|
|> Enum.map(fn id -> worker.new(%{user_id: id}) end)
|
|
|> then(&if &1 != [], do: Oban.insert_all(&1))
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for tracking user registration changes.
|
|
"""
|
|
@spec change_user_registration(User.t(), map()) :: Ecto.Changeset.t()
|
|
def change_user_registration(%User{} = user, attrs \\ %{}) do
|
|
User.registration_changeset(user, attrs, hash_password: false, validate_unique: false)
|
|
end
|
|
|
|
## Settings
|
|
|
|
@doc """
|
|
Checks whether the user is in sudo mode.
|
|
|
|
The user is in sudo mode when the last authentication was done no further
|
|
than 20 minutes ago. The limit can be given as second argument in minutes.
|
|
"""
|
|
@spec sudo_mode?(term(), integer()) :: boolean()
|
|
def sudo_mode?(user, minutes \\ -20)
|
|
|
|
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
|
|
DateTime.after?(ts, DateTime.add(DateTime.utc_now(), minutes, :minute))
|
|
end
|
|
|
|
def sudo_mode?(_user, _minutes), do: false
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for changing the user email.
|
|
|
|
See `Microwaveprop.Accounts.User.email_changeset/3` for a list of supported options.
|
|
|
|
## Examples
|
|
|
|
iex> change_user_email(user)
|
|
%Ecto.Changeset{data: %User{}}
|
|
|
|
"""
|
|
@spec change_user_email(User.t(), map(), keyword()) :: Ecto.Changeset.t()
|
|
def change_user_email(user, attrs \\ %{}, opts \\ []) do
|
|
User.email_changeset(user, attrs, opts)
|
|
end
|
|
|
|
@doc """
|
|
Updates the user email using the given token.
|
|
|
|
If the token matches, the user email is updated and the token is deleted.
|
|
"""
|
|
@spec update_user_email(User.t(), String.t()) :: {:ok, User.t()} | {:error, :transaction_aborted}
|
|
def update_user_email(user, token) do
|
|
context = "change:#{user.email}"
|
|
|
|
Repo.transact(fn ->
|
|
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
|
|
%UserToken{sent_to: email} <- Repo.one(query),
|
|
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
|
|
{_count, _result} <-
|
|
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
|
|
{:ok, user}
|
|
else
|
|
_ -> {:error, :transaction_aborted}
|
|
end
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for changing the user password.
|
|
|
|
See `Microwaveprop.Accounts.User.password_changeset/3` for a list of supported options.
|
|
|
|
## Examples
|
|
|
|
iex> change_user_password(user)
|
|
%Ecto.Changeset{data: %User{}}
|
|
|
|
"""
|
|
@spec change_user_password(User.t(), map(), keyword()) :: Ecto.Changeset.t()
|
|
def change_user_password(user, attrs \\ %{}, opts \\ []) do
|
|
User.password_changeset(user, attrs, opts)
|
|
end
|
|
|
|
@doc """
|
|
Updates the user password.
|
|
|
|
Returns a tuple with the updated user, as well as a list of expired tokens.
|
|
|
|
## Examples
|
|
|
|
iex> update_user_password(user, %{password: ...})
|
|
{:ok, {%User{}, [...]}}
|
|
|
|
iex> update_user_password(user, %{password: "too short"})
|
|
{:error, %Ecto.Changeset{}}
|
|
|
|
"""
|
|
@spec update_user_password(User.t(), map()) ::
|
|
{:ok, {User.t(), [UserToken.t()]}} | {:error, Ecto.Changeset.t()}
|
|
def update_user_password(user, attrs) do
|
|
user
|
|
|> User.password_changeset(attrs)
|
|
|> update_user_and_delete_all_tokens()
|
|
end
|
|
|
|
@doc """
|
|
Builds a home-QTH changeset from the given attrs (no DB write).
|
|
Useful for rendering the form in templates and LiveViews.
|
|
"""
|
|
@spec change_user_home_qth(User.t(), map()) :: Ecto.Changeset.t()
|
|
def change_user_home_qth(%User{} = user, attrs \\ %{}) do
|
|
User.change_home_qth(user, attrs)
|
|
end
|
|
|
|
@doc """
|
|
Updates the user's home QTH (grid + lat/lon + optional elevation).
|
|
"""
|
|
@spec update_user_home_qth(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
|
|
def update_user_home_qth(%User{} = user, attrs) do
|
|
user |> User.change_home_qth(attrs) |> Repo.update()
|
|
end
|
|
|
|
## Session
|
|
|
|
@doc """
|
|
Generates a session token.
|
|
"""
|
|
@spec generate_user_session_token(User.t()) :: binary()
|
|
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.
|
|
|
|
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
|
|
"""
|
|
@spec get_user_by_session_token(binary()) :: {User.t(), DateTime.t()} | nil
|
|
def get_user_by_session_token(token) do
|
|
{:ok, query} = UserToken.verify_session_token_query(token)
|
|
Repo.one(query)
|
|
end
|
|
|
|
## Confirmation
|
|
|
|
@doc ~S"""
|
|
Delivers the account confirmation instructions to the given user.
|
|
|
|
Returns `{:error, :already_confirmed}` if the user is already confirmed.
|
|
"""
|
|
@spec deliver_user_confirmation_instructions(User.t(), (String.t() -> String.t())) ::
|
|
{:ok, Swoosh.Email.t()} | {:error, :already_confirmed}
|
|
def deliver_user_confirmation_instructions(%User{} = 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)
|
|
UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Confirms a user by the given token.
|
|
|
|
On success, the confirmation token (and all other tokens tied to this user)
|
|
are deleted to prevent reuse.
|
|
"""
|
|
@spec confirm_user_by_token(String.t()) ::
|
|
{:ok, {User.t(), [UserToken.t()]}} | {:error, :not_found | Ecto.Changeset.t()}
|
|
def confirm_user_by_token(token) do
|
|
with {:ok, query} <- UserToken.verify_confirm_token_query(token),
|
|
{%User{} = user, _token} <- Repo.one(query) do
|
|
user
|
|
|> User.confirm_changeset()
|
|
|> update_user_and_delete_all_tokens()
|
|
else
|
|
_ -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
@doc ~S"""
|
|
Delivers the update email instructions to the given user.
|
|
|
|
## Examples
|
|
|
|
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
|
|
{:ok, %{to: ..., body: ...}}
|
|
|
|
"""
|
|
@spec deliver_user_update_email_instructions(
|
|
User.t(),
|
|
String.t(),
|
|
(String.t() -> String.t())
|
|
) :: {:ok, Swoosh.Email.t()} | {:error, term()}
|
|
def deliver_user_update_email_instructions(%User{} = 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)
|
|
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
|
end
|
|
|
|
## Password reset
|
|
|
|
@doc ~S"""
|
|
Delivers self-service password-reset instructions to the given user.
|
|
|
|
## Examples
|
|
|
|
iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset-password/#{&1}"))
|
|
{:ok, %{to: ..., body: ...}}
|
|
|
|
"""
|
|
@spec deliver_user_reset_password_instructions(User.t(), (String.t() -> String.t())) ::
|
|
{:ok, Swoosh.Email.t()} | {:error, term()}
|
|
def deliver_user_reset_password_instructions(%User{} = 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)
|
|
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
|
|
end
|
|
|
|
@doc """
|
|
Gets the user by a password-reset token, or nil if the token is invalid or expired.
|
|
"""
|
|
@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_password_reset_token_query(token),
|
|
{%User{} = user, _token} <- Repo.one(query) do
|
|
user
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Resets the user password and revokes all tokens for that user.
|
|
|
|
Returns `{:ok, {user, expired_tokens}}` on success.
|
|
"""
|
|
@spec reset_user_password(User.t(), map()) ::
|
|
{:ok, {User.t(), [UserToken.t()]}} | {:error, Ecto.Changeset.t()}
|
|
def reset_user_password(%User{} = user, attrs) do
|
|
user
|
|
|> User.password_changeset(attrs)
|
|
|> update_user_and_delete_all_tokens()
|
|
end
|
|
|
|
@doc """
|
|
Deletes the signed token with the given context.
|
|
"""
|
|
@spec delete_user_session_token(binary()) :: :ok
|
|
def delete_user_session_token(token) do
|
|
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
|
|
:ok
|
|
end
|
|
|
|
## API tokens (long-lived bearer tokens for /api/v1)
|
|
|
|
@doc """
|
|
Creates a new long-lived API token for the user. Returns the
|
|
plaintext token (only shown once) alongside the persisted record.
|
|
"""
|
|
@spec create_api_token(User.t(), map()) ::
|
|
{:ok, {String.t(), UserApiToken.t()}} | {:error, Ecto.Changeset.t()}
|
|
def create_api_token(%User{} = user, attrs) do
|
|
case UserApiToken.build(user, attrs) do
|
|
{:ok, {plaintext, changeset}} ->
|
|
case Repo.insert(changeset) do
|
|
{:ok, record} -> {:ok, {plaintext, record}}
|
|
{:error, changeset} -> {:error, changeset}
|
|
end
|
|
|
|
{:error, changeset} ->
|
|
{:error, changeset}
|
|
end
|
|
end
|
|
|
|
@doc "Lists every non-revoked API token for the user, newest first."
|
|
@spec list_api_tokens(User.t()) :: [UserApiToken.t()]
|
|
def list_api_tokens(%User{id: user_id}) do
|
|
Repo.all(
|
|
from t in UserApiToken,
|
|
where: t.user_id == ^user_id and is_nil(t.revoked_at),
|
|
order_by: [desc: t.inserted_at]
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Looks up the user owning the given plaintext bearer token. Returns
|
|
`{:ok, user, token}` for valid, unexpired, non-revoked tokens. The
|
|
token's `last_used_at` is updated as a side effect.
|
|
"""
|
|
@spec get_user_by_api_token(String.t()) ::
|
|
{:ok, User.t(), UserApiToken.t()} | {:error, :invalid_token}
|
|
def get_user_by_api_token(plaintext) when is_binary(plaintext) do
|
|
hash = UserApiToken.hash_token(plaintext)
|
|
now = DateTime.utc_now(:second)
|
|
|
|
query =
|
|
from t in UserApiToken,
|
|
join: u in assoc(t, :user),
|
|
where: t.token_hash == ^hash,
|
|
where: is_nil(t.revoked_at),
|
|
where: is_nil(t.expires_at) or t.expires_at > ^now,
|
|
select: {u, t}
|
|
|
|
case Repo.one(query) do
|
|
nil ->
|
|
{:error, :invalid_token}
|
|
|
|
{user, token} ->
|
|
{1, _} =
|
|
Repo.update_all(
|
|
from(t in UserApiToken, where: t.id == ^token.id),
|
|
set: [last_used_at: now]
|
|
)
|
|
|
|
{:ok, user, %{token | last_used_at: now}}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Revokes a user's API token by id. Returns `{:error, :not_found}`
|
|
when the id is not owned by the user.
|
|
"""
|
|
@spec revoke_api_token(User.t(), Ecto.UUID.t()) ::
|
|
{:ok, UserApiToken.t()} | {:error, :not_found}
|
|
def revoke_api_token(%User{id: user_id}, token_id) do
|
|
case Repo.get_by(UserApiToken, id: token_id, user_id: user_id) do
|
|
nil ->
|
|
{:error, :not_found}
|
|
|
|
%UserApiToken{revoked_at: nil} = token ->
|
|
token
|
|
|> Ecto.Changeset.change(revoked_at: DateTime.utc_now(:second))
|
|
|> Repo.update()
|
|
|
|
%UserApiToken{} = token ->
|
|
{:ok, token}
|
|
end
|
|
rescue
|
|
# Malformed UUID in the URL — treat as a clean 404 instead of
|
|
# bubbling a 500 out of `Repo.get_by`'s UUID cast.
|
|
Ecto.Query.CastError -> {:error, :not_found}
|
|
end
|
|
|
|
## Token helper
|
|
|
|
defp update_user_and_delete_all_tokens(changeset) do
|
|
Repo.transact(fn ->
|
|
with {:ok, user} <- Repo.update(changeset) do
|
|
Repo.delete_all(from(t in UserToken, where: t.user_id == ^user.id))
|
|
|
|
{:ok, {user, []}}
|
|
end
|
|
end)
|
|
end
|
|
end
|