analytics/lib/plausible/auth/user_sessions.ex

224 lines
6.2 KiB
Elixir

defmodule Plausible.Auth.UserSessions do
@moduledoc """
Functions for interacting with user sessions.
"""
use Plausible
import Ecto.Query
alias Plausible.Auth
alias Plausible.Repo
@socket_id_prefix "user_sessions:"
@spec list_for_user(Auth.User.t(), NaiveDateTime.t()) :: [Auth.UserSession.t()]
def list_for_user(user, now \\ NaiveDateTime.utc_now(:second)) do
Repo.all(
from us in Auth.UserSession,
where: us.user_id == ^user.id,
where: us.timeout_at >= ^now,
order_by: [desc: us.last_used_at, desc: us.id]
)
end
@spec count_for_users([Auth.User.t()], NaiveDateTime.t()) :: list()
def count_for_users(users, now \\ NaiveDateTime.utc_now(:second)) when is_list(users) do
Repo.all(
from(us in Auth.UserSession,
where: us.user_id in ^Enum.map(users, & &1.id),
where: us.timeout_at >= ^now,
group_by: us.user_id,
select: {us.user_id, count(us.id)},
order_by: [asc: us.user_id]
)
)
end
on_ee do
alias Plausible.Teams
@spec list_sso_for_team(Teams.Team.t(), NaiveDateTime.t()) :: [Auth.UserSession.t()]
def list_sso_for_team(team, now \\ NaiveDateTime.utc_now(:second)) do
user_ids =
Repo.all(
from t in Teams.Team,
inner_join: tm in assoc(t, :team_memberships),
inner_join: u in assoc(tm, :user),
where: t.id == ^team.id,
where: tm.role != :guest,
where: u.type == :sso,
select: u.id
)
Repo.all(
from us in Auth.UserSession,
inner_join: u in assoc(us, :user),
where: us.user_id in ^user_ids,
where: us.timeout_at >= ^now,
order_by: [desc: us.last_used_at, desc: us.id],
preload: [user: u]
)
end
@spec revoke_sso_by_id(Teams.Team.t(), pos_integer()) :: :ok
def revoke_sso_by_id(team, session_id) do
{_, tokens} =
Repo.delete_all(
from us in Auth.UserSession,
inner_join: u in assoc(us, :user),
inner_join: tm in assoc(u, :team_memberships),
where: u.type == :sso,
where: us.id == ^session_id,
where: tm.role != :guest,
where: tm.team_id == ^team.id,
select: us.token
)
case tokens do
[token] ->
disconnect_by_token(token)
_ ->
:pass
end
:ok
end
end
@spec last_used_humanize(Auth.UserSession.t(), NaiveDateTime.t()) :: String.t()
def last_used_humanize(user_session, now \\ NaiveDateTime.utc_now(:second)) do
diff = NaiveDateTime.diff(now, user_session.last_used_at, :hour)
diff_days = NaiveDateTime.diff(now, user_session.last_used_at, :day)
cond do
diff < 1 -> "Just recently"
diff == 1 -> "1 hour ago"
diff < 24 -> "#{diff} hours ago"
diff < 2 * 24 -> "Yesterday"
true -> "#{diff_days} days ago"
end
end
@spec get_by_token(String.t()) ::
{:ok, Auth.UserSession.t()}
| {:error, :not_found}
| {:error, :expired, Auth.UserSession.t()}
def get_by_token(token) do
now = NaiveDateTime.utc_now(:second)
last_team_subscription_query = Plausible.Teams.last_subscription_join_query()
token_query =
from(us in Auth.UserSession,
inner_join: u in assoc(us, :user),
as: :user,
left_join: tm in assoc(u, :team_memberships),
on: tm.role != :guest,
left_join: t in assoc(tm, :team),
as: :team,
left_join: o in assoc(t, :owners),
left_lateral_join: ts in subquery(last_team_subscription_query),
on: true,
where: us.token == ^token,
order_by: t.id,
preload: [user: {u, team_memberships: {tm, team: {t, subscription: ts, owners: o}}}]
)
case Repo.one(token_query) do
%Auth.UserSession{} = user_session ->
if NaiveDateTime.compare(user_session.timeout_at, now) == :gt do
{:ok, user_session}
else
{:error, :expired, user_session}
end
nil ->
{:error, :not_found}
end
end
@spec create!(Auth.User.t(), String.t(), Keyword.t()) :: Auth.UserSession.t()
def create!(user, device_name, opts \\ []) do
user
|> Auth.UserSession.new_session(device_name, opts)
|> Repo.insert!()
end
@spec remove_by_token(String.t()) :: :ok
def remove_by_token(token) do
Repo.delete_all(from us in Auth.UserSession, where: us.token == ^token)
:ok
end
@spec touch(Auth.UserSession.t(), NaiveDateTime.t()) :: Auth.UserSession.t()
def touch(user_session, now \\ NaiveDateTime.utc_now(:second)) do
if NaiveDateTime.diff(now, user_session.last_used_at, :hour) >= 1 do
Plausible.Users.bump_last_seen(user_session.user_id, now)
user_session
|> Repo.preload(:user)
|> Auth.UserSession.touch_session(now)
|> Repo.update!(allow_stale: true)
else
user_session
end
end
@spec revoke_by_id(Auth.User.t(), pos_integer()) :: :ok
def revoke_by_id(user, session_id) do
{_, tokens} =
Repo.delete_all(
from us in Auth.UserSession,
where: us.user_id == ^user.id and us.id == ^session_id,
select: us.token
)
case tokens do
[token] ->
disconnect_by_token(token)
_ ->
:pass
end
:ok
end
@spec revoke_all(Auth.User.t(), Keyword.t()) :: :ok
def revoke_all(user, opts \\ []) do
except = Keyword.get(opts, :except)
delete_query = from us in Auth.UserSession, where: us.user_id == ^user.id, select: us.token
delete_query =
if except do
where(delete_query, [us], us.id != ^except.id)
else
delete_query
end
{_count, tokens} = Repo.delete_all(delete_query)
Enum.each(tokens, &disconnect_by_token/1)
end
@spec disconnect_by_token(String.t()) :: :ok
def disconnect_by_token(token_or_socket_id) do
socket_id =
if String.starts_with?(token_or_socket_id, @socket_id_prefix) do
token_or_socket_id
else
socket_id(token_or_socket_id)
end
PlausibleWeb.Endpoint.broadcast(socket_id, "disconnect", %{})
:ok
end
@spec socket_id(String.t()) :: String.t()
def socket_id(token) do
@socket_id_prefix <> Base.url_encode64(token)
end
end