forked from AkkomaGang/akkoma
a1aacc08a4
Just updating the user triggered post fetching too, now it shouldn't. It only happened in the AP user fetching since that's what's used to update users
1395 lines
36 KiB
Elixir
1395 lines
36 KiB
Elixir
# Pleroma: A lightweight social networking server
|
|
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
defmodule Pleroma.User do
|
|
use Ecto.Schema
|
|
|
|
import Ecto.Changeset
|
|
import Ecto.Query
|
|
|
|
alias Comeonin.Pbkdf2
|
|
alias Pleroma.Activity
|
|
alias Pleroma.Formatter
|
|
alias Pleroma.Notification
|
|
alias Pleroma.Object
|
|
alias Pleroma.Repo
|
|
alias Pleroma.User
|
|
alias Pleroma.Web
|
|
alias Pleroma.Web.ActivityPub.ActivityPub
|
|
alias Pleroma.Web.ActivityPub.Utils
|
|
alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils
|
|
alias Pleroma.Web.OAuth
|
|
alias Pleroma.Web.OStatus
|
|
alias Pleroma.Web.RelMe
|
|
alias Pleroma.Web.Websub
|
|
|
|
require Logger
|
|
|
|
@type t :: %__MODULE__{}
|
|
|
|
@primary_key {:id, Pleroma.FlakeId, autogenerate: true}
|
|
|
|
# credo:disable-for-next-line Credo.Check.Readability.MaxLineLength
|
|
@email_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
|
|
|
|
@strict_local_nickname_regex ~r/^[a-zA-Z\d]+$/
|
|
@extended_local_nickname_regex ~r/^[a-zA-Z\d_-]+$/
|
|
|
|
schema "users" do
|
|
field(:bio, :string)
|
|
field(:email, :string)
|
|
field(:name, :string)
|
|
field(:nickname, :string)
|
|
field(:password_hash, :string)
|
|
field(:password, :string, virtual: true)
|
|
field(:password_confirmation, :string, virtual: true)
|
|
field(:following, {:array, :string}, default: [])
|
|
field(:ap_id, :string)
|
|
field(:avatar, :map)
|
|
field(:local, :boolean, default: true)
|
|
field(:follower_address, :string)
|
|
field(:search_rank, :float, virtual: true)
|
|
field(:tags, {:array, :string}, default: [])
|
|
field(:bookmarks, {:array, :string}, default: [])
|
|
field(:last_refreshed_at, :naive_datetime)
|
|
has_many(:notifications, Notification)
|
|
embeds_one(:info, Pleroma.User.Info)
|
|
|
|
timestamps()
|
|
end
|
|
|
|
def auth_active?(%User{local: false}), do: true
|
|
|
|
def auth_active?(%User{info: %User.Info{confirmation_pending: false}}), do: true
|
|
|
|
def auth_active?(%User{info: %User.Info{confirmation_pending: true}}),
|
|
do: !Pleroma.Config.get([:instance, :account_activation_required])
|
|
|
|
def auth_active?(_), do: false
|
|
|
|
def visible_for?(user, for_user \\ nil)
|
|
|
|
def visible_for?(%User{id: user_id}, %User{id: for_id}) when user_id == for_id, do: true
|
|
|
|
def visible_for?(%User{} = user, for_user) do
|
|
auth_active?(user) || superuser?(for_user)
|
|
end
|
|
|
|
def visible_for?(_, _), do: false
|
|
|
|
def superuser?(%User{local: true, info: %User.Info{is_admin: true}}), do: true
|
|
def superuser?(%User{local: true, info: %User.Info{is_moderator: true}}), do: true
|
|
def superuser?(_), do: false
|
|
|
|
def avatar_url(user) do
|
|
case user.avatar do
|
|
%{"url" => [%{"href" => href} | _]} -> href
|
|
_ -> "#{Web.base_url()}/images/avi.png"
|
|
end
|
|
end
|
|
|
|
def banner_url(user) do
|
|
case user.info.banner do
|
|
%{"url" => [%{"href" => href} | _]} -> href
|
|
_ -> "#{Web.base_url()}/images/banner.png"
|
|
end
|
|
end
|
|
|
|
def profile_url(%User{info: %{source_data: %{"url" => url}}}), do: url
|
|
def profile_url(%User{ap_id: ap_id}), do: ap_id
|
|
def profile_url(_), do: nil
|
|
|
|
def ap_id(%User{nickname: nickname}) do
|
|
"#{Web.base_url()}/users/#{nickname}"
|
|
end
|
|
|
|
def ap_followers(%User{} = user) do
|
|
"#{ap_id(user)}/followers"
|
|
end
|
|
|
|
def user_info(%User{} = user) do
|
|
oneself = if user.local, do: 1, else: 0
|
|
|
|
%{
|
|
following_count: length(user.following) - oneself,
|
|
note_count: user.info.note_count,
|
|
follower_count: user.info.follower_count,
|
|
locked: user.info.locked,
|
|
confirmation_pending: user.info.confirmation_pending,
|
|
default_scope: user.info.default_scope
|
|
}
|
|
end
|
|
|
|
def remote_user_creation(params) do
|
|
params =
|
|
params
|
|
|> Map.put(:info, params[:info] || %{})
|
|
|
|
info_cng = User.Info.remote_user_creation(%User.Info{}, params[:info])
|
|
|
|
changes =
|
|
%User{}
|
|
|> cast(params, [:bio, :name, :ap_id, :nickname, :avatar])
|
|
|> validate_required([:name, :ap_id])
|
|
|> unique_constraint(:nickname)
|
|
|> validate_format(:nickname, @email_regex)
|
|
|> validate_length(:bio, max: 5000)
|
|
|> validate_length(:name, max: 100)
|
|
|> put_change(:local, false)
|
|
|> put_embed(:info, info_cng)
|
|
|
|
if changes.valid? do
|
|
case info_cng.changes[:source_data] do
|
|
%{"followers" => followers} ->
|
|
changes
|
|
|> put_change(:follower_address, followers)
|
|
|
|
_ ->
|
|
followers = User.ap_followers(%User{nickname: changes.changes[:nickname]})
|
|
|
|
changes
|
|
|> put_change(:follower_address, followers)
|
|
end
|
|
else
|
|
changes
|
|
end
|
|
end
|
|
|
|
def update_changeset(struct, params \\ %{}) do
|
|
struct
|
|
|> cast(params, [:bio, :name, :avatar])
|
|
|> unique_constraint(:nickname)
|
|
|> validate_format(:nickname, local_nickname_regex())
|
|
|> validate_length(:bio, max: 5000)
|
|
|> validate_length(:name, min: 1, max: 100)
|
|
end
|
|
|
|
def upgrade_changeset(struct, params \\ %{}) do
|
|
params =
|
|
params
|
|
|> Map.put(:last_refreshed_at, NaiveDateTime.utc_now())
|
|
|
|
info_cng =
|
|
struct.info
|
|
|> User.Info.user_upgrade(params[:info])
|
|
|
|
struct
|
|
|> cast(params, [:bio, :name, :follower_address, :avatar, :last_refreshed_at])
|
|
|> unique_constraint(:nickname)
|
|
|> validate_format(:nickname, local_nickname_regex())
|
|
|> validate_length(:bio, max: 5000)
|
|
|> validate_length(:name, max: 100)
|
|
|> put_embed(:info, info_cng)
|
|
end
|
|
|
|
def password_update_changeset(struct, params) do
|
|
changeset =
|
|
struct
|
|
|> cast(params, [:password, :password_confirmation])
|
|
|> validate_required([:password, :password_confirmation])
|
|
|> validate_confirmation(:password)
|
|
|
|
OAuth.Token.delete_user_tokens(struct)
|
|
OAuth.Authorization.delete_user_authorizations(struct)
|
|
|
|
if changeset.valid? do
|
|
hashed = Pbkdf2.hashpwsalt(changeset.changes[:password])
|
|
|
|
changeset
|
|
|> put_change(:password_hash, hashed)
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
def reset_password(user, data) do
|
|
update_and_set_cache(password_update_changeset(user, data))
|
|
end
|
|
|
|
def register_changeset(struct, params \\ %{}, opts \\ []) do
|
|
confirmation_status =
|
|
if opts[:confirmed] || !Pleroma.Config.get([:instance, :account_activation_required]) do
|
|
:confirmed
|
|
else
|
|
:unconfirmed
|
|
end
|
|
|
|
info_change = User.Info.confirmation_changeset(%User.Info{}, confirmation_status)
|
|
|
|
changeset =
|
|
struct
|
|
|> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation])
|
|
|> validate_required([:email, :name, :nickname, :password, :password_confirmation])
|
|
|> validate_confirmation(:password)
|
|
|> unique_constraint(:email)
|
|
|> unique_constraint(:nickname)
|
|
|> validate_exclusion(:nickname, Pleroma.Config.get([Pleroma.User, :restricted_nicknames]))
|
|
|> validate_format(:nickname, local_nickname_regex())
|
|
|> validate_format(:email, @email_regex)
|
|
|> validate_length(:bio, max: 1000)
|
|
|> validate_length(:name, min: 1, max: 100)
|
|
|> put_change(:info, info_change)
|
|
|
|
if changeset.valid? do
|
|
hashed = Pbkdf2.hashpwsalt(changeset.changes[:password])
|
|
ap_id = User.ap_id(%User{nickname: changeset.changes[:nickname]})
|
|
followers = User.ap_followers(%User{nickname: changeset.changes[:nickname]})
|
|
|
|
changeset
|
|
|> put_change(:password_hash, hashed)
|
|
|> put_change(:ap_id, ap_id)
|
|
|> unique_constraint(:ap_id)
|
|
|> put_change(:following, [followers])
|
|
|> put_change(:follower_address, followers)
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp autofollow_users(user) do
|
|
candidates = Pleroma.Config.get([:instance, :autofollowed_nicknames])
|
|
|
|
autofollowed_users =
|
|
from(u in User,
|
|
where: u.local == true,
|
|
where: u.nickname in ^candidates
|
|
)
|
|
|> Repo.all()
|
|
|
|
follow_all(user, autofollowed_users)
|
|
end
|
|
|
|
@doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)"
|
|
def register(%Ecto.Changeset{} = changeset) do
|
|
with {:ok, user} <- Repo.insert(changeset),
|
|
{:ok, user} <- autofollow_users(user),
|
|
{:ok, _} <- Pleroma.User.WelcomeMessage.post_welcome_message_to_user(user),
|
|
{:ok, _} <- try_send_confirmation_email(user) do
|
|
{:ok, user}
|
|
end
|
|
end
|
|
|
|
def try_send_confirmation_email(%User{} = user) do
|
|
if user.info.confirmation_pending &&
|
|
Pleroma.Config.get([:instance, :account_activation_required]) do
|
|
user
|
|
|> Pleroma.UserEmail.account_confirmation_email()
|
|
|> Pleroma.Mailer.deliver_async()
|
|
else
|
|
{:ok, :noop}
|
|
end
|
|
end
|
|
|
|
def needs_update?(%User{local: true}), do: false
|
|
|
|
def needs_update?(%User{local: false, last_refreshed_at: nil}), do: true
|
|
|
|
def needs_update?(%User{local: false} = user) do
|
|
NaiveDateTime.diff(NaiveDateTime.utc_now(), user.last_refreshed_at) >= 86_400
|
|
end
|
|
|
|
def needs_update?(_), do: true
|
|
|
|
def maybe_direct_follow(%User{} = follower, %User{local: true, info: %{locked: true}}) do
|
|
{:ok, follower}
|
|
end
|
|
|
|
def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do
|
|
follow(follower, followed)
|
|
end
|
|
|
|
def maybe_direct_follow(%User{} = follower, %User{} = followed) do
|
|
if not User.ap_enabled?(followed) do
|
|
follow(follower, followed)
|
|
else
|
|
{:ok, follower}
|
|
end
|
|
end
|
|
|
|
def maybe_follow(%User{} = follower, %User{info: _info} = followed) do
|
|
if not following?(follower, followed) do
|
|
follow(follower, followed)
|
|
else
|
|
{:ok, follower}
|
|
end
|
|
end
|
|
|
|
@doc "A mass follow for local users. Respects blocks in both directions but does not create activities."
|
|
@spec follow_all(User.t(), list(User.t())) :: {atom(), User.t()}
|
|
def follow_all(follower, followeds) do
|
|
followed_addresses =
|
|
followeds
|
|
|> Enum.reject(fn followed -> blocks?(follower, followed) || blocks?(followed, follower) end)
|
|
|> Enum.map(fn %{follower_address: fa} -> fa end)
|
|
|
|
q =
|
|
from(u in User,
|
|
where: u.id == ^follower.id,
|
|
update: [
|
|
set: [
|
|
following:
|
|
fragment(
|
|
"array(select distinct unnest (array_cat(?, ?)))",
|
|
u.following,
|
|
^followed_addresses
|
|
)
|
|
]
|
|
]
|
|
)
|
|
|
|
{1, [follower]} = Repo.update_all(q, [], returning: true)
|
|
|
|
Enum.each(followeds, fn followed ->
|
|
update_follower_count(followed)
|
|
end)
|
|
|
|
set_cache(follower)
|
|
end
|
|
|
|
def follow(%User{} = follower, %User{info: info} = followed) do
|
|
user_config = Application.get_env(:pleroma, :user)
|
|
deny_follow_blocked = Keyword.get(user_config, :deny_follow_blocked)
|
|
|
|
ap_followers = followed.follower_address
|
|
|
|
cond do
|
|
following?(follower, followed) or info.deactivated ->
|
|
{:error, "Could not follow user: #{followed.nickname} is already on your list."}
|
|
|
|
deny_follow_blocked and blocks?(followed, follower) ->
|
|
{:error, "Could not follow user: #{followed.nickname} blocked you."}
|
|
|
|
true ->
|
|
if !followed.local && follower.local && !ap_enabled?(followed) do
|
|
Websub.subscribe(follower, followed)
|
|
end
|
|
|
|
q =
|
|
from(u in User,
|
|
where: u.id == ^follower.id,
|
|
update: [push: [following: ^ap_followers]]
|
|
)
|
|
|
|
{1, [follower]} = Repo.update_all(q, [], returning: true)
|
|
|
|
{:ok, _} = update_follower_count(followed)
|
|
|
|
set_cache(follower)
|
|
end
|
|
end
|
|
|
|
def unfollow(%User{} = follower, %User{} = followed) do
|
|
ap_followers = followed.follower_address
|
|
|
|
if following?(follower, followed) and follower.ap_id != followed.ap_id do
|
|
q =
|
|
from(u in User,
|
|
where: u.id == ^follower.id,
|
|
update: [pull: [following: ^ap_followers]]
|
|
)
|
|
|
|
{1, [follower]} = Repo.update_all(q, [], returning: true)
|
|
|
|
{:ok, followed} = update_follower_count(followed)
|
|
|
|
set_cache(follower)
|
|
|
|
{:ok, follower, Utils.fetch_latest_follow(follower, followed)}
|
|
else
|
|
{:error, "Not subscribed!"}
|
|
end
|
|
end
|
|
|
|
@spec following?(User.t(), User.t()) :: boolean
|
|
def following?(%User{} = follower, %User{} = followed) do
|
|
Enum.member?(follower.following, followed.follower_address)
|
|
end
|
|
|
|
def follow_import(%User{} = follower, followed_identifiers)
|
|
when is_list(followed_identifiers) do
|
|
Enum.map(
|
|
followed_identifiers,
|
|
fn followed_identifier ->
|
|
with %User{} = followed <- get_or_fetch(followed_identifier),
|
|
{:ok, follower} <- maybe_direct_follow(follower, followed),
|
|
{:ok, _} <- ActivityPub.follow(follower, followed) do
|
|
followed
|
|
else
|
|
err ->
|
|
Logger.debug("follow_import failed for #{followed_identifier} with: #{inspect(err)}")
|
|
err
|
|
end
|
|
end
|
|
)
|
|
end
|
|
|
|
def locked?(%User{} = user) do
|
|
user.info.locked || false
|
|
end
|
|
|
|
def get_by_id(id) do
|
|
Repo.get_by(User, id: id)
|
|
end
|
|
|
|
def get_by_ap_id(ap_id) do
|
|
Repo.get_by(User, ap_id: ap_id)
|
|
end
|
|
|
|
# This is mostly an SPC migration fix. This guesses the user nickname by taking the last part
|
|
# of the ap_id and the domain and tries to get that user
|
|
def get_by_guessed_nickname(ap_id) do
|
|
domain = URI.parse(ap_id).host
|
|
name = List.last(String.split(ap_id, "/"))
|
|
nickname = "#{name}@#{domain}"
|
|
|
|
get_by_nickname(nickname)
|
|
end
|
|
|
|
def set_cache(user) do
|
|
Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user)
|
|
Cachex.put(:user_cache, "nickname:#{user.nickname}", user)
|
|
Cachex.put(:user_cache, "user_info:#{user.id}", user_info(user))
|
|
{:ok, user}
|
|
end
|
|
|
|
def update_and_set_cache(changeset) do
|
|
with {:ok, user} <- Repo.update(changeset) do
|
|
set_cache(user)
|
|
else
|
|
e -> e
|
|
end
|
|
end
|
|
|
|
def invalidate_cache(user) do
|
|
Cachex.del(:user_cache, "ap_id:#{user.ap_id}")
|
|
Cachex.del(:user_cache, "nickname:#{user.nickname}")
|
|
Cachex.del(:user_cache, "user_info:#{user.id}")
|
|
end
|
|
|
|
def get_cached_by_ap_id(ap_id) do
|
|
key = "ap_id:#{ap_id}"
|
|
Cachex.fetch!(:user_cache, key, fn _ -> get_by_ap_id(ap_id) end)
|
|
end
|
|
|
|
def get_cached_by_id(id) do
|
|
key = "id:#{id}"
|
|
|
|
ap_id =
|
|
Cachex.fetch!(:user_cache, key, fn _ ->
|
|
user = get_by_id(id)
|
|
|
|
if user do
|
|
Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user)
|
|
{:commit, user.ap_id}
|
|
else
|
|
{:ignore, ""}
|
|
end
|
|
end)
|
|
|
|
get_cached_by_ap_id(ap_id)
|
|
end
|
|
|
|
def get_cached_by_nickname(nickname) do
|
|
key = "nickname:#{nickname}"
|
|
Cachex.fetch!(:user_cache, key, fn _ -> get_or_fetch_by_nickname(nickname) end)
|
|
end
|
|
|
|
def get_cached_by_nickname_or_id(nickname_or_id) do
|
|
get_cached_by_id(nickname_or_id) || get_cached_by_nickname(nickname_or_id)
|
|
end
|
|
|
|
def get_by_nickname(nickname) do
|
|
Repo.get_by(User, nickname: nickname) ||
|
|
if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do
|
|
Repo.get_by(User, nickname: local_nickname(nickname))
|
|
end
|
|
end
|
|
|
|
def get_by_nickname_or_email(nickname_or_email) do
|
|
case user = Repo.get_by(User, nickname: nickname_or_email) do
|
|
%User{} -> user
|
|
nil -> Repo.get_by(User, email: nickname_or_email)
|
|
end
|
|
end
|
|
|
|
def get_cached_user_info(user) do
|
|
key = "user_info:#{user.id}"
|
|
Cachex.fetch!(:user_cache, key, fn _ -> user_info(user) end)
|
|
end
|
|
|
|
def fetch_by_nickname(nickname) do
|
|
ap_try = ActivityPub.make_user_from_nickname(nickname)
|
|
|
|
case ap_try do
|
|
{:ok, user} -> {:ok, user}
|
|
_ -> OStatus.make_user(nickname)
|
|
end
|
|
end
|
|
|
|
def get_or_fetch_by_nickname(nickname) do
|
|
with %User{} = user <- get_by_nickname(nickname) do
|
|
user
|
|
else
|
|
_e ->
|
|
with [_nick, _domain] <- String.split(nickname, "@"),
|
|
{:ok, user} <- fetch_by_nickname(nickname) do
|
|
if Pleroma.Config.get([:fetch_initial_posts, :enabled]) do
|
|
{:ok, _} = Task.start(__MODULE__, :fetch_initial_posts, [user])
|
|
end
|
|
|
|
user
|
|
else
|
|
_e -> nil
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc "Fetch some posts when the user has just been federated with"
|
|
def fetch_initial_posts(user) do
|
|
pages = Pleroma.Config.get!([:fetch_initial_posts, :pages])
|
|
|
|
Enum.each(
|
|
# Insert all the posts in reverse order, so they're in the right order on the timeline
|
|
Enum.reverse(Utils.fetch_ordered_collection(user.info.source_data["outbox"], pages)),
|
|
&Pleroma.Web.Federator.incoming_ap_doc/1
|
|
)
|
|
end
|
|
|
|
def get_followers_query(%User{id: id, follower_address: follower_address}, nil) do
|
|
from(
|
|
u in User,
|
|
where: fragment("? <@ ?", ^[follower_address], u.following),
|
|
where: u.id != ^id
|
|
)
|
|
end
|
|
|
|
def get_followers_query(user, page) do
|
|
from(u in get_followers_query(user, nil))
|
|
|> paginate(page, 20)
|
|
end
|
|
|
|
def get_followers_query(user), do: get_followers_query(user, nil)
|
|
|
|
def get_followers(user, page \\ nil) do
|
|
q = get_followers_query(user, page)
|
|
|
|
{:ok, Repo.all(q)}
|
|
end
|
|
|
|
def get_followers_ids(user, page \\ nil) do
|
|
q = get_followers_query(user, page)
|
|
|
|
Repo.all(from(u in q, select: u.id))
|
|
end
|
|
|
|
def get_friends_query(%User{id: id, following: following}, nil) do
|
|
from(
|
|
u in User,
|
|
where: u.follower_address in ^following,
|
|
where: u.id != ^id
|
|
)
|
|
end
|
|
|
|
def get_friends_query(user, page) do
|
|
from(u in get_friends_query(user, nil))
|
|
|> paginate(page, 20)
|
|
end
|
|
|
|
def get_friends_query(user), do: get_friends_query(user, nil)
|
|
|
|
def get_friends(user, page \\ nil) do
|
|
q = get_friends_query(user, page)
|
|
|
|
{:ok, Repo.all(q)}
|
|
end
|
|
|
|
def get_friends_ids(user, page \\ nil) do
|
|
q = get_friends_query(user, page)
|
|
|
|
Repo.all(from(u in q, select: u.id))
|
|
end
|
|
|
|
def get_follow_requests_query(%User{} = user) do
|
|
from(
|
|
a in Activity,
|
|
where:
|
|
fragment(
|
|
"? ->> 'type' = 'Follow'",
|
|
a.data
|
|
),
|
|
where:
|
|
fragment(
|
|
"? ->> 'state' = 'pending'",
|
|
a.data
|
|
),
|
|
where:
|
|
fragment(
|
|
"coalesce((?)->'object'->>'id', (?)->>'object') = ?",
|
|
a.data,
|
|
a.data,
|
|
^user.ap_id
|
|
)
|
|
)
|
|
end
|
|
|
|
def get_follow_requests(%User{} = user) do
|
|
users =
|
|
user
|
|
|> User.get_follow_requests_query()
|
|
|> join(:inner, [a], u in User, a.actor == u.ap_id)
|
|
|> where([a, u], not fragment("? @> ?", u.following, ^[user.follower_address]))
|
|
|> group_by([a, u], u.id)
|
|
|> select([a, u], u)
|
|
|> Repo.all()
|
|
|
|
{:ok, users}
|
|
end
|
|
|
|
def increase_note_count(%User{} = user) do
|
|
User
|
|
|> where(id: ^user.id)
|
|
|> update([u],
|
|
set: [
|
|
info:
|
|
fragment(
|
|
"jsonb_set(?, '{note_count}', ((?->>'note_count')::int + 1)::varchar::jsonb, true)",
|
|
u.info,
|
|
u.info
|
|
)
|
|
]
|
|
)
|
|
|> Repo.update_all([], returning: true)
|
|
|> case do
|
|
{1, [user]} -> set_cache(user)
|
|
_ -> {:error, user}
|
|
end
|
|
end
|
|
|
|
def decrease_note_count(%User{} = user) do
|
|
User
|
|
|> where(id: ^user.id)
|
|
|> update([u],
|
|
set: [
|
|
info:
|
|
fragment(
|
|
"jsonb_set(?, '{note_count}', (greatest(0, (?->>'note_count')::int - 1))::varchar::jsonb, true)",
|
|
u.info,
|
|
u.info
|
|
)
|
|
]
|
|
)
|
|
|> Repo.update_all([], returning: true)
|
|
|> case do
|
|
{1, [user]} -> set_cache(user)
|
|
_ -> {:error, user}
|
|
end
|
|
end
|
|
|
|
def update_note_count(%User{} = user) do
|
|
note_count_query =
|
|
from(
|
|
a in Object,
|
|
where: fragment("?->>'actor' = ? and ?->>'type' = 'Note'", a.data, ^user.ap_id, a.data),
|
|
select: count(a.id)
|
|
)
|
|
|
|
note_count = Repo.one(note_count_query)
|
|
|
|
info_cng = User.Info.set_note_count(user.info, note_count)
|
|
|
|
cng =
|
|
change(user)
|
|
|> put_embed(:info, info_cng)
|
|
|
|
update_and_set_cache(cng)
|
|
end
|
|
|
|
def update_follower_count(%User{} = user) do
|
|
follower_count_query =
|
|
User
|
|
|> where([u], ^user.follower_address in u.following)
|
|
|> where([u], u.id != ^user.id)
|
|
|> select([u], %{count: count(u.id)})
|
|
|
|
User
|
|
|> where(id: ^user.id)
|
|
|> join(:inner, [u], s in subquery(follower_count_query))
|
|
|> update([u, s],
|
|
set: [
|
|
info:
|
|
fragment(
|
|
"jsonb_set(?, '{follower_count}', ?::varchar::jsonb, true)",
|
|
u.info,
|
|
s.count
|
|
)
|
|
]
|
|
)
|
|
|> Repo.update_all([], returning: true)
|
|
|> case do
|
|
{1, [user]} -> set_cache(user)
|
|
_ -> {:error, user}
|
|
end
|
|
end
|
|
|
|
def get_users_from_set_query(ap_ids, false) do
|
|
from(
|
|
u in User,
|
|
where: u.ap_id in ^ap_ids
|
|
)
|
|
end
|
|
|
|
def get_users_from_set_query(ap_ids, true) do
|
|
query = get_users_from_set_query(ap_ids, false)
|
|
|
|
from(
|
|
u in query,
|
|
where: u.local == true
|
|
)
|
|
end
|
|
|
|
def get_users_from_set(ap_ids, local_only \\ true) do
|
|
get_users_from_set_query(ap_ids, local_only)
|
|
|> Repo.all()
|
|
end
|
|
|
|
def get_recipients_from_activity(%Activity{recipients: to}) do
|
|
query =
|
|
from(
|
|
u in User,
|
|
where: u.ap_id in ^to,
|
|
or_where: fragment("? && ?", u.following, ^to)
|
|
)
|
|
|
|
query = from(u in query, where: u.local == true)
|
|
|
|
Repo.all(query)
|
|
end
|
|
|
|
@spec search_for_admin(%{
|
|
local: boolean(),
|
|
page: number(),
|
|
page_size: number()
|
|
}) :: {:ok, [Pleroma.User.t()], number()}
|
|
def search_for_admin(%{query: nil, local: local, page: page, page_size: page_size}) do
|
|
query =
|
|
from(u in User, order_by: u.id)
|
|
|> maybe_local_user_query(local)
|
|
|
|
paginated_query =
|
|
query
|
|
|> paginate(page, page_size)
|
|
|
|
count =
|
|
query
|
|
|> Repo.aggregate(:count, :id)
|
|
|
|
{:ok, Repo.all(paginated_query), count}
|
|
end
|
|
|
|
@spec search_for_admin(%{
|
|
query: binary(),
|
|
admin: Pleroma.User.t(),
|
|
local: boolean(),
|
|
page: number(),
|
|
page_size: number()
|
|
}) :: {:ok, [Pleroma.User.t()], number()}
|
|
def search_for_admin(%{
|
|
query: term,
|
|
admin: admin,
|
|
local: local,
|
|
page: page,
|
|
page_size: page_size
|
|
}) do
|
|
term = String.trim_leading(term, "@")
|
|
|
|
local_paginated_query =
|
|
User
|
|
|> maybe_local_user_query(local)
|
|
|> paginate(page, page_size)
|
|
|
|
search_query = fts_search_subquery(term, local_paginated_query)
|
|
|
|
count =
|
|
term
|
|
|> fts_search_subquery()
|
|
|> maybe_local_user_query(local)
|
|
|> Repo.aggregate(:count, :id)
|
|
|
|
{:ok, do_search(search_query, admin), count}
|
|
end
|
|
|
|
def search(query, resolve \\ false, for_user \\ nil) do
|
|
# Strip the beginning @ off if there is a query
|
|
query = String.trim_leading(query, "@")
|
|
|
|
if resolve, do: get_or_fetch(query)
|
|
|
|
fts_results = do_search(fts_search_subquery(query), for_user)
|
|
|
|
{:ok, trigram_results} =
|
|
Repo.transaction(fn ->
|
|
Ecto.Adapters.SQL.query(Repo, "select set_limit(0.25)", [])
|
|
do_search(trigram_search_subquery(query), for_user)
|
|
end)
|
|
|
|
Enum.uniq_by(fts_results ++ trigram_results, & &1.id)
|
|
end
|
|
|
|
defp do_search(subquery, for_user, options \\ []) do
|
|
q =
|
|
from(
|
|
s in subquery(subquery),
|
|
order_by: [desc: s.search_rank],
|
|
limit: ^(options[:limit] || 20)
|
|
)
|
|
|
|
results =
|
|
q
|
|
|> Repo.all()
|
|
|> Enum.filter(&(&1.search_rank > 0))
|
|
|
|
boost_search_results(results, for_user)
|
|
end
|
|
|
|
defp fts_search_subquery(term, query \\ User) do
|
|
processed_query =
|
|
term
|
|
|> String.replace(~r/\W+/, " ")
|
|
|> String.trim()
|
|
|> String.split()
|
|
|> Enum.map(&(&1 <> ":*"))
|
|
|> Enum.join(" | ")
|
|
|
|
from(
|
|
u in query,
|
|
select_merge: %{
|
|
search_rank:
|
|
fragment(
|
|
"""
|
|
ts_rank_cd(
|
|
setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') ||
|
|
setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B'),
|
|
to_tsquery('simple', ?),
|
|
32
|
|
)
|
|
""",
|
|
u.nickname,
|
|
u.name,
|
|
^processed_query
|
|
)
|
|
},
|
|
where:
|
|
fragment(
|
|
"""
|
|
(setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') ||
|
|
setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B')) @@ to_tsquery('simple', ?)
|
|
""",
|
|
u.nickname,
|
|
u.name,
|
|
^processed_query
|
|
)
|
|
)
|
|
end
|
|
|
|
defp trigram_search_subquery(term) do
|
|
from(
|
|
u in User,
|
|
select_merge: %{
|
|
search_rank:
|
|
fragment(
|
|
"similarity(?, trim(? || ' ' || coalesce(?, '')))",
|
|
^term,
|
|
u.nickname,
|
|
u.name
|
|
)
|
|
},
|
|
where: fragment("trim(? || ' ' || coalesce(?, '')) % ?", u.nickname, u.name, ^term)
|
|
)
|
|
end
|
|
|
|
defp boost_search_results(results, nil), do: results
|
|
|
|
defp boost_search_results(results, for_user) do
|
|
friends_ids = get_friends_ids(for_user)
|
|
followers_ids = get_followers_ids(for_user)
|
|
|
|
Enum.map(
|
|
results,
|
|
fn u ->
|
|
search_rank_coef =
|
|
cond do
|
|
u.id in friends_ids ->
|
|
1.2
|
|
|
|
u.id in followers_ids ->
|
|
1.1
|
|
|
|
true ->
|
|
1
|
|
end
|
|
|
|
Map.put(u, :search_rank, u.search_rank * search_rank_coef)
|
|
end
|
|
)
|
|
|> Enum.sort_by(&(-&1.search_rank))
|
|
end
|
|
|
|
def blocks_import(%User{} = blocker, blocked_identifiers) when is_list(blocked_identifiers) do
|
|
Enum.map(
|
|
blocked_identifiers,
|
|
fn blocked_identifier ->
|
|
with %User{} = blocked <- get_or_fetch(blocked_identifier),
|
|
{:ok, blocker} <- block(blocker, blocked),
|
|
{:ok, _} <- ActivityPub.block(blocker, blocked) do
|
|
blocked
|
|
else
|
|
err ->
|
|
Logger.debug("blocks_import failed for #{blocked_identifier} with: #{inspect(err)}")
|
|
err
|
|
end
|
|
end
|
|
)
|
|
end
|
|
|
|
def mute(muter, %User{ap_id: ap_id}) do
|
|
info_cng =
|
|
muter.info
|
|
|> User.Info.add_to_mutes(ap_id)
|
|
|
|
cng =
|
|
change(muter)
|
|
|> put_embed(:info, info_cng)
|
|
|
|
update_and_set_cache(cng)
|
|
end
|
|
|
|
def unmute(muter, %{ap_id: ap_id}) do
|
|
info_cng =
|
|
muter.info
|
|
|> User.Info.remove_from_mutes(ap_id)
|
|
|
|
cng =
|
|
change(muter)
|
|
|> put_embed(:info, info_cng)
|
|
|
|
update_and_set_cache(cng)
|
|
end
|
|
|
|
def block(blocker, %User{ap_id: ap_id} = blocked) do
|
|
# sever any follow relationships to prevent leaks per activitypub (Pleroma issue #213)
|
|
blocker =
|
|
if following?(blocker, blocked) do
|
|
{:ok, blocker, _} = unfollow(blocker, blocked)
|
|
blocker
|
|
else
|
|
blocker
|
|
end
|
|
|
|
if following?(blocked, blocker) do
|
|
unfollow(blocked, blocker)
|
|
end
|
|
|
|
info_cng =
|
|
blocker.info
|
|
|> User.Info.add_to_block(ap_id)
|
|
|
|
cng =
|
|
change(blocker)
|
|
|> put_embed(:info, info_cng)
|
|
|
|
update_and_set_cache(cng)
|
|
end
|
|
|
|
# helper to handle the block given only an actor's AP id
|
|
def block(blocker, %{ap_id: ap_id}) do
|
|
block(blocker, User.get_by_ap_id(ap_id))
|
|
end
|
|
|
|
def unblock(blocker, %{ap_id: ap_id}) do
|
|
info_cng =
|
|
blocker.info
|
|
|> User.Info.remove_from_block(ap_id)
|
|
|
|
cng =
|
|
change(blocker)
|
|
|> put_embed(:info, info_cng)
|
|
|
|
update_and_set_cache(cng)
|
|
end
|
|
|
|
def mutes?(nil, _), do: false
|
|
def mutes?(user, %{ap_id: ap_id}), do: Enum.member?(user.info.mutes, ap_id)
|
|
|
|
def blocks?(user, %{ap_id: ap_id}) do
|
|
blocks = user.info.blocks
|
|
domain_blocks = user.info.domain_blocks
|
|
%{host: host} = URI.parse(ap_id)
|
|
|
|
Enum.member?(blocks, ap_id) ||
|
|
Enum.any?(domain_blocks, fn domain ->
|
|
host == domain
|
|
end)
|
|
end
|
|
|
|
def muted_users(user),
|
|
do: Repo.all(from(u in User, where: u.ap_id in ^user.info.mutes))
|
|
|
|
def blocked_users(user),
|
|
do: Repo.all(from(u in User, where: u.ap_id in ^user.info.blocks))
|
|
|
|
def block_domain(user, domain) do
|
|
info_cng =
|
|
user.info
|
|
|> User.Info.add_to_domain_block(domain)
|
|
|
|
cng =
|
|
change(user)
|
|
|> put_embed(:info, info_cng)
|
|
|
|
update_and_set_cache(cng)
|
|
end
|
|
|
|
def unblock_domain(user, domain) do
|
|
info_cng =
|
|
user.info
|
|
|> User.Info.remove_from_domain_block(domain)
|
|
|
|
cng =
|
|
change(user)
|
|
|> put_embed(:info, info_cng)
|
|
|
|
update_and_set_cache(cng)
|
|
end
|
|
|
|
def maybe_local_user_query(query, local) do
|
|
if local, do: local_user_query(query), else: query
|
|
end
|
|
|
|
def local_user_query(query \\ User) do
|
|
from(
|
|
u in query,
|
|
where: u.local == true,
|
|
where: not is_nil(u.nickname)
|
|
)
|
|
end
|
|
|
|
def active_local_user_query do
|
|
from(
|
|
u in local_user_query(),
|
|
where: fragment("not (?->'deactivated' @> 'true')", u.info)
|
|
)
|
|
end
|
|
|
|
def moderator_user_query do
|
|
from(
|
|
u in User,
|
|
where: u.local == true,
|
|
where: fragment("?->'is_moderator' @> 'true'", u.info)
|
|
)
|
|
end
|
|
|
|
def deactivate(%User{} = user, status \\ true) do
|
|
info_cng = User.Info.set_activation_status(user.info, status)
|
|
|
|
cng =
|
|
change(user)
|
|
|> put_embed(:info, info_cng)
|
|
|
|
update_and_set_cache(cng)
|
|
end
|
|
|
|
def delete(%User{} = user) do
|
|
{:ok, user} = User.deactivate(user)
|
|
|
|
# Remove all relationships
|
|
{:ok, followers} = User.get_followers(user)
|
|
|
|
followers
|
|
|> Enum.each(fn follower -> User.unfollow(follower, user) end)
|
|
|
|
{:ok, friends} = User.get_friends(user)
|
|
|
|
friends
|
|
|> Enum.each(fn followed -> User.unfollow(user, followed) end)
|
|
|
|
query = from(a in Activity, where: a.actor == ^user.ap_id)
|
|
|
|
Repo.all(query)
|
|
|> Enum.each(fn activity ->
|
|
case activity.data["type"] do
|
|
"Create" ->
|
|
ActivityPub.delete(Object.normalize(activity.data["object"]))
|
|
|
|
# TODO: Do something with likes, follows, repeats.
|
|
_ ->
|
|
"Doing nothing"
|
|
end
|
|
end)
|
|
|
|
{:ok, user}
|
|
end
|
|
|
|
def html_filter_policy(%User{info: %{no_rich_text: true}}) do
|
|
Pleroma.HTML.Scrubber.TwitterText
|
|
end
|
|
|
|
@default_scrubbers Pleroma.Config.get([:markup, :scrub_policy])
|
|
|
|
def html_filter_policy(_), do: @default_scrubbers
|
|
|
|
def fetch_by_ap_id(ap_id) do
|
|
ap_try = ActivityPub.make_user_from_ap_id(ap_id)
|
|
|
|
case ap_try do
|
|
{:ok, user} ->
|
|
user
|
|
|
|
_ ->
|
|
case OStatus.make_user(ap_id) do
|
|
{:ok, user} -> user
|
|
_ -> {:error, "Could not fetch by AP id"}
|
|
end
|
|
end
|
|
end
|
|
|
|
def get_or_fetch_by_ap_id(ap_id) do
|
|
user = get_by_ap_id(ap_id)
|
|
|
|
if !is_nil(user) and !User.needs_update?(user) do
|
|
user
|
|
else
|
|
# Whether to fetch initial posts for the user (if it's a new user & the fetching is enabled)
|
|
should_fetch_initial = is_nil(user) and Pleroma.Config.get([:fetch_initial_posts, :enabled])
|
|
|
|
user = fetch_by_ap_id(ap_id)
|
|
|
|
if should_fetch_initial do
|
|
with %User{} = user do
|
|
{:ok, _} = Task.start(__MODULE__, :fetch_initial_posts, [user])
|
|
end
|
|
end
|
|
|
|
user
|
|
end
|
|
end
|
|
|
|
def get_or_create_instance_user do
|
|
relay_uri = "#{Pleroma.Web.Endpoint.url()}/relay"
|
|
|
|
if user = get_by_ap_id(relay_uri) do
|
|
user
|
|
else
|
|
changes =
|
|
%User{info: %User.Info{}}
|
|
|> cast(%{}, [:ap_id, :nickname, :local])
|
|
|> put_change(:ap_id, relay_uri)
|
|
|> put_change(:nickname, nil)
|
|
|> put_change(:local, true)
|
|
|> put_change(:follower_address, relay_uri <> "/followers")
|
|
|
|
{:ok, user} = Repo.insert(changes)
|
|
user
|
|
end
|
|
end
|
|
|
|
# AP style
|
|
def public_key_from_info(%{
|
|
source_data: %{"publicKey" => %{"publicKeyPem" => public_key_pem}}
|
|
}) do
|
|
key =
|
|
public_key_pem
|
|
|> :public_key.pem_decode()
|
|
|> hd()
|
|
|> :public_key.pem_entry_decode()
|
|
|
|
{:ok, key}
|
|
end
|
|
|
|
# OStatus Magic Key
|
|
def public_key_from_info(%{magic_key: magic_key}) do
|
|
{:ok, Pleroma.Web.Salmon.decode_key(magic_key)}
|
|
end
|
|
|
|
def get_public_key_for_ap_id(ap_id) do
|
|
with %User{} = user <- get_or_fetch_by_ap_id(ap_id),
|
|
{:ok, public_key} <- public_key_from_info(user.info) do
|
|
{:ok, public_key}
|
|
else
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
defp blank?(""), do: nil
|
|
defp blank?(n), do: n
|
|
|
|
def insert_or_update_user(data) do
|
|
data =
|
|
data
|
|
|> Map.put(:name, blank?(data[:name]) || data[:nickname])
|
|
|
|
cs = User.remote_user_creation(data)
|
|
|
|
Repo.insert(cs, on_conflict: :replace_all, conflict_target: :nickname)
|
|
end
|
|
|
|
def ap_enabled?(%User{local: true}), do: true
|
|
def ap_enabled?(%User{info: info}), do: info.ap_enabled
|
|
def ap_enabled?(_), do: false
|
|
|
|
@doc "Gets or fetch a user by uri or nickname."
|
|
@spec get_or_fetch(String.t()) :: User.t()
|
|
def get_or_fetch("http" <> _host = uri), do: get_or_fetch_by_ap_id(uri)
|
|
def get_or_fetch(nickname), do: get_or_fetch_by_nickname(nickname)
|
|
|
|
# wait a period of time and return newest version of the User structs
|
|
# this is because we have synchronous follow APIs and need to simulate them
|
|
# with an async handshake
|
|
def wait_and_refresh(_, %User{local: true} = a, %User{local: true} = b) do
|
|
with %User{} = a <- Repo.get(User, a.id),
|
|
%User{} = b <- Repo.get(User, b.id) do
|
|
{:ok, a, b}
|
|
else
|
|
_e ->
|
|
:error
|
|
end
|
|
end
|
|
|
|
def wait_and_refresh(timeout, %User{} = a, %User{} = b) do
|
|
with :ok <- :timer.sleep(timeout),
|
|
%User{} = a <- Repo.get(User, a.id),
|
|
%User{} = b <- Repo.get(User, b.id) do
|
|
{:ok, a, b}
|
|
else
|
|
_e ->
|
|
:error
|
|
end
|
|
end
|
|
|
|
def parse_bio(bio, user \\ %User{info: %{source_data: %{}}})
|
|
def parse_bio(nil, _user), do: ""
|
|
def parse_bio(bio, _user) when bio == "", do: bio
|
|
|
|
def parse_bio(bio, user) do
|
|
emoji =
|
|
(user.info.source_data["tag"] || [])
|
|
|> Enum.filter(fn %{"type" => t} -> t == "Emoji" end)
|
|
|> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} ->
|
|
{String.trim(name, ":"), url}
|
|
end)
|
|
|
|
# TODO: get profile URLs other than user.ap_id
|
|
profile_urls = [user.ap_id]
|
|
|
|
bio
|
|
|> CommonUtils.format_input("text/plain",
|
|
mentions_format: :full,
|
|
rel: &RelMe.maybe_put_rel_me(&1, profile_urls)
|
|
)
|
|
|> elem(0)
|
|
|> Formatter.emojify(emoji)
|
|
end
|
|
|
|
def tag(user_identifiers, tags) when is_list(user_identifiers) do
|
|
Repo.transaction(fn ->
|
|
for user_identifier <- user_identifiers, do: tag(user_identifier, tags)
|
|
end)
|
|
end
|
|
|
|
def tag(nickname, tags) when is_binary(nickname),
|
|
do: tag(User.get_by_nickname(nickname), tags)
|
|
|
|
def tag(%User{} = user, tags),
|
|
do: update_tags(user, Enum.uniq((user.tags || []) ++ normalize_tags(tags)))
|
|
|
|
def untag(user_identifiers, tags) when is_list(user_identifiers) do
|
|
Repo.transaction(fn ->
|
|
for user_identifier <- user_identifiers, do: untag(user_identifier, tags)
|
|
end)
|
|
end
|
|
|
|
def untag(nickname, tags) when is_binary(nickname),
|
|
do: untag(User.get_by_nickname(nickname), tags)
|
|
|
|
def untag(%User{} = user, tags),
|
|
do: update_tags(user, (user.tags || []) -- normalize_tags(tags))
|
|
|
|
defp update_tags(%User{} = user, new_tags) do
|
|
{:ok, updated_user} =
|
|
user
|
|
|> change(%{tags: new_tags})
|
|
|> update_and_set_cache()
|
|
|
|
updated_user
|
|
end
|
|
|
|
def bookmark(%User{} = user, status_id) do
|
|
bookmarks = Enum.uniq(user.bookmarks ++ [status_id])
|
|
update_bookmarks(user, bookmarks)
|
|
end
|
|
|
|
def unbookmark(%User{} = user, status_id) do
|
|
bookmarks = Enum.uniq(user.bookmarks -- [status_id])
|
|
update_bookmarks(user, bookmarks)
|
|
end
|
|
|
|
def update_bookmarks(%User{} = user, bookmarks) do
|
|
user
|
|
|> change(%{bookmarks: bookmarks})
|
|
|> update_and_set_cache
|
|
end
|
|
|
|
defp normalize_tags(tags) do
|
|
[tags]
|
|
|> List.flatten()
|
|
|> Enum.map(&String.downcase(&1))
|
|
end
|
|
|
|
defp local_nickname_regex do
|
|
if Pleroma.Config.get([:instance, :extended_nickname_format]) do
|
|
@extended_local_nickname_regex
|
|
else
|
|
@strict_local_nickname_regex
|
|
end
|
|
end
|
|
|
|
def local_nickname(nickname_or_mention) do
|
|
nickname_or_mention
|
|
|> full_nickname()
|
|
|> String.split("@")
|
|
|> hd()
|
|
end
|
|
|
|
def full_nickname(nickname_or_mention),
|
|
do: String.trim_leading(nickname_or_mention, "@")
|
|
|
|
def error_user(ap_id) do
|
|
%User{
|
|
name: ap_id,
|
|
ap_id: ap_id,
|
|
info: %User.Info{},
|
|
nickname: "erroruser@example.com",
|
|
inserted_at: NaiveDateTime.utc_now()
|
|
}
|
|
end
|
|
|
|
def all_superusers do
|
|
from(
|
|
u in User,
|
|
where: u.local == true,
|
|
where: fragment("?->'is_admin' @> 'true' OR ?->'is_moderator' @> 'true'", u.info, u.info)
|
|
)
|
|
|> Repo.all()
|
|
end
|
|
|
|
defp paginate(query, page, page_size) do
|
|
from(u in query,
|
|
limit: ^page_size,
|
|
offset: ^((page - 1) * page_size)
|
|
)
|
|
end
|
|
|
|
def showing_reblogs?(%User{} = user, %User{} = target) do
|
|
target.ap_id not in user.info.muted_reblogs
|
|
end
|
|
end
|