Merge pull request 'Tweak search' (#1113) from Oneric/akkoma:search-overhaul into develop
Reviewed-on: #1113
This commit is contained in:
commit
fb392a8562
33 changed files with 1094 additions and 769 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -6,9 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Update note
|
||||
- If you are using database search with a non-default RUM index,
|
||||
you _MUST_ apply the new optional RUM migration before upgrading.
|
||||
Then after upgrading you wil need to refresh your RUM index setup
|
||||
to also get the new search behaviour. This can be done by "changing"
|
||||
your text search config to your current value (or something else, like `simple` if you so wish)
|
||||
via the `database set_text_search_config <value>` mix task
|
||||
|
||||
## Added
|
||||
- federated voter count of polls is now parsed and federated out too;
|
||||
this fixes vote percetanges for new and refreshed remote multi-selection polls
|
||||
- new config options to restrict unauthenticated search API access under `:pleroma, :restrict_unauthenticated, :search`
|
||||
|
||||
### Fixed
|
||||
- fixed status search not respecting `resolve=false`
|
||||
- fixed later search result pages again fetching remote content
|
||||
|
||||
### Changed
|
||||
- New installations (not existing instances) now default to the `simple` full-text-search config
|
||||
- Unauthenticated search requests now by default force-disable remote fetches and pagination
|
||||
- Post search can now match text in the content warning with the database provider
|
||||
- prefixing a user search query with `@` will limit results to matching nicknames only, if the query contains no enclosed whitespace
|
||||
|
||||
|
||||
## 2026.05 (3.19.0)
|
||||
|
|
|
|||
|
|
@ -869,7 +869,8 @@ private_instance? = :if_instance_is_private
|
|||
config :pleroma, :restrict_unauthenticated,
|
||||
timelines: %{local: private_instance?, federated: private_instance?, bubble: true},
|
||||
profiles: %{local: private_instance?, remote: private_instance?},
|
||||
activities: %{local: private_instance?, remote: private_instance?}
|
||||
activities: %{local: private_instance?, remote: private_instance?},
|
||||
search: %{all: private_instance?, resolve: true, paginate: true}
|
||||
|
||||
config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false
|
||||
|
||||
|
|
|
|||
|
|
@ -3115,6 +3115,29 @@ config :pleroma, :config_description, [
|
|||
description: "Disallow viewing remote posts."
|
||||
}
|
||||
]
|
||||
},
|
||||
%{
|
||||
key: :search,
|
||||
type: :map,
|
||||
description: "Settings for search endpoints.",
|
||||
children: [
|
||||
%{
|
||||
key: :all,
|
||||
type: :boolean,
|
||||
description: "Disallow search access entirely."
|
||||
},
|
||||
%{
|
||||
key: :resolve,
|
||||
type: :boolean,
|
||||
description: "Disallow fetching not-yet-known remote content via search."
|
||||
},
|
||||
%{
|
||||
key: :paginate,
|
||||
type: :boolean,
|
||||
description:
|
||||
"Disallow traversing past the first page of results (search pagination can be inefficient)."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ defmodule Mix.Tasks.Pleroma.Benchmark do
|
|||
|
||||
Benchee.run(%{
|
||||
"search" => fn ->
|
||||
Pleroma.Activity.search(nil, "cofe")
|
||||
Pleroma.Search.DatabaseSearch.search(nil, "cofe", resolve: false)
|
||||
end
|
||||
})
|
||||
end
|
||||
|
|
|
|||
|
|
@ -597,7 +597,7 @@ defmodule Mix.Tasks.Pleroma.Database do
|
|||
Ecto.Adapters.SQL.query!(
|
||||
Pleroma.Repo,
|
||||
"CREATE OR REPLACE FUNCTION objects_fts_update() RETURNS trigger AS $$ BEGIN
|
||||
new.fts_content := to_tsvector(new.data->>'content');
|
||||
new.fts_content := to_tsvector(COALESCE(new.data->>'summary', '') || ' ' || (new.data->>'content'));
|
||||
RETURN new;
|
||||
END
|
||||
$$ LANGUAGE plpgsql",
|
||||
|
|
@ -612,7 +612,14 @@ defmodule Mix.Tasks.Pleroma.Database do
|
|||
|
||||
Ecto.Adapters.SQL.query!(
|
||||
Pleroma.Repo,
|
||||
"CREATE INDEX CONCURRENTLY objects_fts ON objects USING gin(to_tsvector('#{tsconfig}', data->>'content')); ",
|
||||
"""
|
||||
CREATE INDEX CONCURRENTLY objects_fts ON objects USING gin(
|
||||
to_tsvector(
|
||||
'#{tsconfig}',
|
||||
COALESCE(data->>'summary', '') || ' ' || (data->>'content')
|
||||
)
|
||||
);
|
||||
""",
|
||||
[],
|
||||
timeout: :infinity
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,19 +41,7 @@ defmodule Mix.Tasks.Pleroma.NotificationSettings do
|
|||
end
|
||||
|
||||
defp build_query(hide_notification_contents, options) do
|
||||
query =
|
||||
from(u in Pleroma.User,
|
||||
where: u.local,
|
||||
update: [
|
||||
set: [
|
||||
notification_settings:
|
||||
fragment(
|
||||
"jsonb_set(notification_settings, '{hide_notification_contents}', ?)",
|
||||
^hide_notification_contents
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
criteria = %{internal: :allowed, local: true}
|
||||
|
||||
user_emails =
|
||||
options
|
||||
|
|
@ -62,11 +50,11 @@ defmodule Mix.Tasks.Pleroma.NotificationSettings do
|
|||
|> Enum.map(&String.trim(&1))
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|
||||
query =
|
||||
criteria =
|
||||
if length(user_emails) > 0 do
|
||||
where(query, [u], u.email in ^user_emails)
|
||||
Map.put(criteria, :email, user_emails)
|
||||
else
|
||||
query
|
||||
criteria
|
||||
end
|
||||
|
||||
user_nicknames =
|
||||
|
|
@ -76,13 +64,23 @@ defmodule Mix.Tasks.Pleroma.NotificationSettings do
|
|||
|> Enum.map(&String.trim(&1))
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|
||||
query =
|
||||
criteria =
|
||||
if length(user_nicknames) > 0 do
|
||||
where(query, [u], u.nickname in ^user_nicknames)
|
||||
Map.put(criteria, :nickname, user_nicknames)
|
||||
else
|
||||
query
|
||||
criteria
|
||||
end
|
||||
|
||||
query
|
||||
criteria
|
||||
|> Pleroma.User.Query.build(criteria)
|
||||
|> update([u],
|
||||
set: [
|
||||
notification_settings:
|
||||
fragment(
|
||||
"jsonb_set(notification_settings, '{hide_notification_contents}', ?)",
|
||||
^hide_notification_contents
|
||||
)
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ defmodule Mix.Tasks.Pleroma.User do
|
|||
def run(["deactivate_all_from_instance", instance]) do
|
||||
start_pleroma()
|
||||
|
||||
Pleroma.User.Query.build(%{nickname: "@#{instance}"})
|
||||
Pleroma.User.Query.build(%{nickname_suffix: "@#{instance}"})
|
||||
|> Pleroma.Repo.chunk_stream(500, :batches)
|
||||
|> Stream.each(fn users ->
|
||||
users
|
||||
|
|
|
|||
|
|
@ -412,8 +412,6 @@ defmodule Pleroma.Activity do
|
|||
)
|
||||
end
|
||||
|
||||
defdelegate search(user, query, options \\ []), to: Pleroma.Search.DatabaseSearch
|
||||
|
||||
def direct_conversation_id(activity, for_user) do
|
||||
alias Pleroma.Conversation.Participation
|
||||
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ defmodule Pleroma.Instances.Instance do
|
|||
end
|
||||
|
||||
def perform(:delete_instance, host) when is_binary(host) do
|
||||
User.Query.build(%{nickname: "@#{host}"})
|
||||
User.Query.build(%{nickname_suffix: "@#{host}"})
|
||||
|> Repo.chunk_stream(100, :batches)
|
||||
|> Stream.each(fn users ->
|
||||
users
|
||||
|
|
|
|||
|
|
@ -146,9 +146,7 @@ defmodule Pleroma.Notification do
|
|||
fragment(
|
||||
# "(actor's domain NOT in domain_blocks)"
|
||||
"""
|
||||
NOT (
|
||||
substring(? from '.*://([^/]*)') = ANY(?)
|
||||
)
|
||||
split_part(?, '/', 3) <> ALL(?)
|
||||
""",
|
||||
activity.actor,
|
||||
^user.domain_blocks
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
defmodule Pleroma.Search.DatabaseSearch do
|
||||
alias Pleroma.Activity
|
||||
alias Pleroma.Object
|
||||
alias Pleroma.Object.Fetcher
|
||||
alias Pleroma.Pagination
|
||||
alias Pleroma.Repo
|
||||
|
|
@ -17,6 +18,46 @@ defmodule Pleroma.Search.DatabaseSearch do
|
|||
@behaviour Pleroma.Search.SearchBackend
|
||||
|
||||
def search(user, search_query, options \\ []) do
|
||||
apid_match = is_uri(search_query) && maybe_locate_apid(search_query, user, options)
|
||||
|
||||
if apid_match do
|
||||
[apid_match]
|
||||
else
|
||||
fts_search(user, search_query, options)
|
||||
end
|
||||
end
|
||||
|
||||
defp is_uri("https://" <> _), do: true
|
||||
defp is_uri("http://" <> _), do: true
|
||||
defp is_uri(_), do: false
|
||||
|
||||
defp should_resolve_remote(options) do
|
||||
options[:resolve] && Keyword.get(options, :offset, 0) == 0
|
||||
end
|
||||
|
||||
def maybe_locate_apid(apid, user, options) do
|
||||
known = Object.get_by_ap_id(apid)
|
||||
|
||||
object_res =
|
||||
cond do
|
||||
known != nil -> {:ok, known}
|
||||
should_resolve_remote(options) -> Fetcher.fetch_object_from_id(apid)
|
||||
true -> nil
|
||||
end
|
||||
|
||||
with {:ok, %Object{} = object} <- object_res,
|
||||
%Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
|
||||
true <- activity.local || !should_restrict_local(user),
|
||||
true <- Visibility.visible_for_user?(activity, user) do
|
||||
activity
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def maybe_locate_uri(_, _, _), do: nil
|
||||
|
||||
defp fts_search(user, search_query, options) do
|
||||
gin_limit = Pleroma.Config.get([__MODULE__, :gin_fuzzy_search_limit])
|
||||
|
||||
try do
|
||||
|
|
@ -24,22 +65,21 @@ defmodule Pleroma.Search.DatabaseSearch do
|
|||
Repo.transact(fn ->
|
||||
# SET LOCAL statement cannot be parametrised it seems; safe since integer
|
||||
Repo.query!("SET LOCAL gin_fuzzy_search_limit TO #{gin_limit}", [])
|
||||
{:ok, do_query(user, search_query, options)}
|
||||
{:ok, do_fts_query(user, search_query, options)}
|
||||
end)
|
||||
|> then(fn
|
||||
{:ok, result} -> result
|
||||
error -> raise "#{__MODULE__}: db search transaction failed: #{inspect(error)}"
|
||||
end)
|
||||
else
|
||||
do_query(user, search_query, options)
|
||||
do_fts_query(user, search_query, options)
|
||||
end
|
||||
|> maybe_fetch(user, search_query)
|
||||
rescue
|
||||
_ -> maybe_fetch([], user, search_query)
|
||||
_ -> []
|
||||
end
|
||||
end
|
||||
|
||||
def do_query(user, search_query, options) do
|
||||
defp do_fts_query(user, search_query, options) do
|
||||
index_type = if Pleroma.Config.get([:database, :rum_enabled]), do: :rum, else: :gin
|
||||
limit = Enum.min([Keyword.get(options, :limit), 40])
|
||||
offset = Keyword.get(options, :offset, 0)
|
||||
|
|
@ -78,30 +118,46 @@ defmodule Pleroma.Search.DatabaseSearch do
|
|||
)
|
||||
end
|
||||
|
||||
defp query_with(q, :gin, search_query) do
|
||||
defp get_text_search_config() do
|
||||
%{rows: [[tsc]]} =
|
||||
Ecto.Adapters.SQL.query!(
|
||||
Pleroma.Repo,
|
||||
"select current_setting('default_text_search_config')::regconfig::oid;"
|
||||
)
|
||||
|
||||
tsc
|
||||
end
|
||||
|
||||
defp query_with(q, :gin, search_query) do
|
||||
tsc = get_text_search_config()
|
||||
|
||||
from([a, o] in q,
|
||||
where:
|
||||
fragment(
|
||||
"to_tsvector(?::oid::regconfig, ?->>'content') @@ websearch_to_tsquery(?)",
|
||||
"""
|
||||
to_tsvector(
|
||||
?::oid::regconfig,
|
||||
COALESCE(?->>'summary', '') || ' ' || (?->>'content')
|
||||
) @@ websearch_to_tsquery(?::oid::regconfig, ?)
|
||||
""",
|
||||
^tsc,
|
||||
o.data,
|
||||
o.data,
|
||||
^tsc,
|
||||
^search_query
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp query_with(q, :rum, search_query) do
|
||||
tsc = get_text_search_config()
|
||||
|
||||
from([a, o] in q,
|
||||
where:
|
||||
fragment(
|
||||
"? @@ websearch_to_tsquery(?)",
|
||||
"? @@ websearch_to_tsquery(?::oid::regconfig, ?)",
|
||||
o.fts_content,
|
||||
^tsc,
|
||||
^search_query
|
||||
),
|
||||
order_by: [fragment("? <=> now()::date", o.inserted_at)]
|
||||
|
|
@ -127,16 +183,4 @@ defmodule Pleroma.Search.DatabaseSearch do
|
|||
end
|
||||
|
||||
defp restrict_local(q), do: where(q, local: true)
|
||||
|
||||
def maybe_fetch(activities, user, search_query) do
|
||||
with false <- should_restrict_local(user),
|
||||
true <- Regex.match?(~r/https?:/, search_query),
|
||||
{:ok, object} <- Fetcher.fetch_object_from_id(search_query),
|
||||
%Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
|
||||
true <- Visibility.visible_for_user?(activity, user) do
|
||||
[activity | activities]
|
||||
else
|
||||
_ -> activities
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@ defmodule Pleroma.Search.Elasticsearch do
|
|||
}
|
||||
end
|
||||
|
||||
defp maybe_fetch(:activity, search_query) do
|
||||
with true <- Regex.match?(~r/https?:/, search_query),
|
||||
defp maybe_fetch(:activity, search_query, options) do
|
||||
with true <- options[:resolve],
|
||||
0 <- Keyword.get(options, :offset, 0),
|
||||
true <- Regex.match?(~r/https?:/, search_query),
|
||||
{:ok, object} <- Fetcher.fetch_object_from_id(search_query),
|
||||
%Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]) do
|
||||
activity
|
||||
|
|
@ -51,7 +53,7 @@ defmodule Pleroma.Search.Elasticsearch do
|
|||
|
||||
activity_fetch_task =
|
||||
Task.async(fn ->
|
||||
maybe_fetch(:activity, String.trim(query))
|
||||
maybe_fetch(:activity, String.trim(query), options)
|
||||
end)
|
||||
|
||||
activity_task =
|
||||
|
|
|
|||
|
|
@ -87,6 +87,16 @@ defmodule Pleroma.Search.Meilisearch do
|
|||
)
|
||||
end
|
||||
|
||||
defp maybe_fetch(activities, user, search_query, options) do
|
||||
located = maybe_locate_apid(user, search_query, options)
|
||||
|
||||
if located != nil do
|
||||
[located | activities]
|
||||
else
|
||||
activities
|
||||
end
|
||||
end
|
||||
|
||||
def search(user, query, options \\ []) do
|
||||
limit = Enum.min([Keyword.get(options, :limit), 40])
|
||||
offset = Keyword.get(options, :offset, 0)
|
||||
|
|
@ -106,10 +116,10 @@ defmodule Pleroma.Search.Meilisearch do
|
|||
|> maybe_restrict_local(user)
|
||||
|> maybe_restrict_author(author)
|
||||
|> maybe_restrict_blocked(user)
|
||||
|> maybe_fetch(user, query)
|
||||
|> maybe_fetch(user, query, options)
|
||||
|> Pleroma.Repo.all()
|
||||
rescue
|
||||
_ -> maybe_fetch([], user, query)
|
||||
_ -> maybe_fetch([], user, query, options)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -289,8 +289,6 @@ defmodule Pleroma.User do
|
|||
defdelegate following_ap_ids(user), to: FollowingRelationship
|
||||
defdelegate get_follow_requests_query(user), to: FollowingRelationship
|
||||
|
||||
defdelegate search(query, opts \\ []), to: User.Search
|
||||
|
||||
@doc """
|
||||
Dumps Flake Id to SQL-compatible format (16-byte UUID).
|
||||
E.g. "9pQtDGXuq4p3VlcJEm" -> <<0, 0, 1, 110, 179, 218, 42, 92, 213, 41, 44, 227, 95, 213, 0, 0>>
|
||||
|
|
@ -502,7 +500,7 @@ defmodule Pleroma.User do
|
|||
|> cast(params, [:name], empty_values: [])
|
||||
|> validate_required([:ap_id])
|
||||
|> validate_required([:name], trim: false)
|
||||
|> unique_constraint(:nickname)
|
||||
|> unique_constraint(:nickname, name: :users_casefolded_nickname_index)
|
||||
|> cast_assoc(:signing_key, with: &SigningKey.remote_changeset/2, required: false)
|
||||
|> validate_format(:nickname, @email_regex)
|
||||
|> validate_length(:bio, max: bio_limit)
|
||||
|
|
@ -561,7 +559,7 @@ defmodule Pleroma.User do
|
|||
:accepts_direct_messages_from
|
||||
]
|
||||
)
|
||||
|> unique_constraint(:nickname)
|
||||
|> unique_constraint(:nickname, name: :users_casefolded_nickname_index)
|
||||
|> validate_format(:nickname, local_nickname_regex())
|
||||
|> validate_length(:bio, max: bio_limit)
|
||||
|> validate_length(:name, min: 1, max: name_limit)
|
||||
|
|
@ -793,7 +791,7 @@ defmodule Pleroma.User do
|
|||
:email
|
||||
])
|
||||
|> validate_required([:name, :nickname])
|
||||
|> unique_constraint(:nickname)
|
||||
|> unique_constraint(:nickname, name: :users_casefolded_nickname_index)
|
||||
|> validate_exclusion(:nickname, Config.get([User, :restricted_nicknames]))
|
||||
|> validate_format(:nickname, local_nickname_regex())
|
||||
|> put_ap_id()
|
||||
|
|
@ -850,7 +848,7 @@ defmodule Pleroma.User do
|
|||
|
||||
if valid?, do: [], else: [email: "Invalid email"]
|
||||
end)
|
||||
|> unique_constraint(:nickname)
|
||||
|> unique_constraint(:nickname, name: :users_casefolded_nickname_index)
|
||||
|> validate_exclusion(:nickname, Config.get([User, :restricted_nicknames]))
|
||||
|> validate_format(:nickname, local_nickname_regex())
|
||||
|> validate_length(:bio, max: bio_limit)
|
||||
|
|
@ -1268,6 +1266,10 @@ defmodule Pleroma.User do
|
|||
get_cached_by_ap_id(ap_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads matching cached user. If not found will fallback to database lookup.
|
||||
If not locally known yet at all, the handle will be looked up on the network via WebFinger.
|
||||
"""
|
||||
def get_cached_by_nickname(nickname) do
|
||||
if String.valid?(nickname) do
|
||||
key = "nickname:#{nickname}"
|
||||
|
|
@ -1304,10 +1306,15 @@ defmodule Pleroma.User do
|
|||
@spec get_by_nickname(String.t()) :: User.t() | nil
|
||||
def get_by_nickname(nickname) do
|
||||
if String.valid?(nickname) do
|
||||
Repo.get_by(User, nickname: nickname) ||
|
||||
search_nick =
|
||||
if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do
|
||||
Repo.get_by(User, nickname: local_nickname(nickname))
|
||||
local_nickname(nickname)
|
||||
else
|
||||
nickname
|
||||
end
|
||||
|
||||
User.Query.build(%{internal: :allowed, nickname: search_nick})
|
||||
|> Repo.one()
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
|
@ -2117,7 +2124,7 @@ defmodule Pleroma.User do
|
|||
}
|
||||
|> change
|
||||
|> put_private_key()
|
||||
|> unique_constraint(:nickname)
|
||||
|> unique_constraint(:nickname, name: :users_casefolded_nickname_index)
|
||||
|> Repo.insert()
|
||||
|> set_cache()
|
||||
end
|
||||
|
|
@ -2365,10 +2372,8 @@ defmodule Pleroma.User do
|
|||
end
|
||||
|
||||
def get_ap_ids_by_nicknames(nicknames) do
|
||||
from(u in User,
|
||||
where: u.nickname in ^nicknames,
|
||||
select: u.ap_id
|
||||
)
|
||||
User.Query.build(%{internal: :allowed, nickname: nicknames})
|
||||
|> select([u], u.ap_id)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -34,10 +34,9 @@ defmodule Pleroma.User.Query do
|
|||
|
||||
@type criteria ::
|
||||
%{
|
||||
query: String.t(),
|
||||
tags: [String.t()],
|
||||
name: String.t(),
|
||||
email: String.t(),
|
||||
email: String.t() | [String.t()],
|
||||
local: boolean(),
|
||||
external: boolean(),
|
||||
active: boolean(),
|
||||
|
|
@ -55,6 +54,8 @@ defmodule Pleroma.User.Query do
|
|||
friends: User.t(),
|
||||
recipients_from_activity: [String.t()],
|
||||
nickname: [String.t()] | String.t(),
|
||||
nickname_substr: String.t(),
|
||||
nickname_suffix: String.t(),
|
||||
ap_id: [String.t()],
|
||||
order_by: term(),
|
||||
select: term(),
|
||||
|
|
@ -63,9 +64,9 @@ defmodule Pleroma.User.Query do
|
|||
}
|
||||
| map()
|
||||
|
||||
@ilike_criteria [:nickname, :name, :query]
|
||||
@ilike_criteria [:nickname_substr, :name]
|
||||
@equal_criteria [:email]
|
||||
@contains_criteria [:ap_id, :nickname]
|
||||
@contains_criteria [:ap_id, :email]
|
||||
|
||||
@spec build(Query.t(), criteria()) :: Query.t()
|
||||
def build(query \\ base_query(), criteria) do
|
||||
|
|
@ -92,9 +93,16 @@ defmodule Pleroma.User.Query do
|
|||
|
||||
defp compose_query({key, value}, query)
|
||||
when key in @ilike_criteria and not_empty_string(value) do
|
||||
# hack for :query key
|
||||
key = if key == :query, do: :nickname, else: key
|
||||
where(query, [u], ilike(field(u, ^key), ^"%#{value}%"))
|
||||
key = if key == :nickname_substr, do: :nickname, else: key
|
||||
where(query, [u], ilike(field(u, ^key), ^"%#{escape_sql_like(value)}%"))
|
||||
end
|
||||
|
||||
defp compose_query({:nickname_suffix, value}, query) when not_empty_string(value) do
|
||||
where(query, [u], ilike(u.nickname, ^"%#{escape_sql_like(value)}"))
|
||||
end
|
||||
|
||||
defp compose_query({:nickname, nick}, query) when not_empty_string(nick) do
|
||||
where(query, [u], fragment("LOWER(?) = LOWER(?)", u.nickname, ^nick))
|
||||
end
|
||||
|
||||
defp compose_query({:invisible, bool}, query) when is_boolean(bool) do
|
||||
|
|
@ -110,6 +118,17 @@ defmodule Pleroma.User.Query do
|
|||
where(query, [u], field(u, ^key) in ^values)
|
||||
end
|
||||
|
||||
defp compose_query({:nickname, nicks}, query) when is_list(nicks) do
|
||||
where(
|
||||
query,
|
||||
[u],
|
||||
fragment("LOWER(?)", u.nickname) in fragment(
|
||||
"(SELECT LOWER(UNNEST(?::text[])))",
|
||||
^nicks
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp compose_query({:tags, tags}, query) when is_list(tags) and length(tags) > 0 do
|
||||
where(query, [u], fragment("? && ?", u.tags, ^tags))
|
||||
end
|
||||
|
|
@ -222,7 +241,7 @@ defmodule Pleroma.User.Query do
|
|||
defp compose_query({:internal, false}, query) do
|
||||
query
|
||||
|> where([u], not is_nil(u.nickname))
|
||||
|> where([u], not like(u.nickname, "internal.%"))
|
||||
|> where([u], fragment("LOWER(?) NOT LIKE 'internal.%'", u.nickname))
|
||||
end
|
||||
|
||||
defp compose_query(_unsupported_param, query), do: query
|
||||
|
|
@ -230,4 +249,12 @@ defmodule Pleroma.User.Query do
|
|||
defp location_query(query, local) do
|
||||
where(query, [u], u.local == ^local)
|
||||
end
|
||||
|
||||
defp escape_sql_like(literal) do
|
||||
# https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-LIKE
|
||||
# (assumes the default config of standard_conforming_strings=on)
|
||||
literal
|
||||
|> String.replace("%", "\\%")
|
||||
|> String.replace("_", "\\_")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
||||
# Copyright © 2026 Akkoma Authors <https://akkoma.dev/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.User.Search do
|
||||
alias Pleroma.EctoType.ActivityPub.ObjectValidators.Uri, as: UriType
|
||||
alias Pleroma.Pagination
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User
|
||||
|
|
@ -15,50 +15,184 @@ defmodule Pleroma.User.Search do
|
|||
@limit 20
|
||||
|
||||
def search(query_string, opts \\ []) do
|
||||
resolve = Keyword.get(opts, :resolve, false)
|
||||
query_string = String.trim(query_string)
|
||||
do_search(query_string, opts)
|
||||
end
|
||||
|
||||
defp do_search("", _opts) do
|
||||
[]
|
||||
end
|
||||
|
||||
defp do_search(query_string, opts) do
|
||||
for_user = Keyword.get(opts, :for_user)
|
||||
local_only = should_restrict_local(for_user)
|
||||
resolve = Keyword.get(opts, :resolve, false) && !local_only
|
||||
|
||||
is_uri = Regex.match?(~r/https?:/, query_string)
|
||||
explicit_nick = String.starts_with?(query_string, "@") && !String.contains?(query_string, " ")
|
||||
|
||||
likely_nick =
|
||||
explicit_nick ||
|
||||
(String.contains?(query_string, "@") && !String.contains?(query_string, " "))
|
||||
|
||||
[]
|
||||
|> maybe_search(fn -> uri_match(query_string, is_uri, resolve) end)
|
||||
|> maybe_search(fn -> nick_match(query_string, likely_nick, resolve) end)
|
||||
|> maybe_search(fn ->
|
||||
fuzzy_matches(query_string, explicit_nick, for_user, local_only, opts)
|
||||
end)
|
||||
|> Enum.filter(&(&1 && (!local_only || &1.local)))
|
||||
end
|
||||
|
||||
defp maybe_search([], finder) do
|
||||
case finder.() do
|
||||
[_ | _] = res -> res
|
||||
{:ok, a} when a != nil -> [a]
|
||||
{:error, _} -> []
|
||||
nil -> []
|
||||
[] -> []
|
||||
a -> [a]
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_search(previous_results, _), do: previous_results
|
||||
|
||||
defp do_uri_match(normalised_uri, resolve) do
|
||||
# This intentional omits block filters.
|
||||
# If an exactly matching URI is searched, there’s clearly intent to see the account anyway.
|
||||
# Similarly, if there’s a known, non-local exact match, we don't need to bother with a
|
||||
# fuzzy search even if results are later filtered to local-only. Thus always return non-local matches.
|
||||
known =
|
||||
from(u in User)
|
||||
|> where([u], u.ap_id == ^normalised_uri or u.uri == ^normalised_uri)
|
||||
|> filter_invisible_users()
|
||||
|> filter_internal_users()
|
||||
|> filter_deactivated_users()
|
||||
|> Repo.all()
|
||||
|
||||
cond do
|
||||
known != [] -> known
|
||||
resolve -> User.fetch_by_ap_id(normalised_uri)
|
||||
true -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp uri_match(uri, true, resolve) do
|
||||
with p = %URI{} <- URI.parse(uri),
|
||||
host when host != nil <- p.host do
|
||||
normalised_host = String.to_charlist(host) |> :idna.encode() |> to_string
|
||||
normalised_uri = %{p | host: normalised_host} |> URI.to_string()
|
||||
do_uri_match(normalised_uri, resolve)
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp uri_match(_, _, _), do: nil
|
||||
|
||||
# NOTE: User.get_cached_by_nickname falls back to a netowrk lookup if not cached. DO NOT USE
|
||||
defp do_nick_match(nick, true), do: User.get_or_fetch_by_nickname(nick)
|
||||
defp do_nick_match(nick, false), do: User.get_by_nickname(nick)
|
||||
defp do_nick_match(_, _), do: nil
|
||||
|
||||
defp verify_and_normalise_nick(nick) do
|
||||
nick =
|
||||
nick
|
||||
|> String.trim_leading("@")
|
||||
|> String.trim_trailing("@#{Pleroma.Web.WebFinger.Schema.domain()}")
|
||||
|> String.trim_trailing("@#{local_domain()}")
|
||||
|
||||
case String.split(nick, "@", parts: 3) do
|
||||
"" ->
|
||||
nil
|
||||
|
||||
# local nick
|
||||
[nick] ->
|
||||
nick
|
||||
|
||||
# remote nick; maybe Unicode domain
|
||||
[name, domain] ->
|
||||
if Regex.match?(~r/[!-\,|@|?|<|>|[-`|{-~|\/|:|\s]/, domain) do
|
||||
nil
|
||||
else
|
||||
encoded_domain =
|
||||
domain
|
||||
|> String.to_charlist()
|
||||
|> :idna.encode()
|
||||
|
||||
"#{name}@#{encoded_domain}"
|
||||
end
|
||||
|
||||
# not a valid nick
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp nick_match(nick, true, resolve) do
|
||||
normalised_nick = verify_and_normalise_nick(nick)
|
||||
|
||||
if normalised_nick,
|
||||
do: do_nick_match(normalised_nick, resolve),
|
||||
else: nil
|
||||
end
|
||||
|
||||
defp nick_match(_, _, _), do: nil
|
||||
|
||||
defp fuzzy_matches(query_string, explicit_nick, for_user, local_only, opts) do
|
||||
following = Keyword.get(opts, :following, false)
|
||||
result_limit = Keyword.get(opts, :limit, @limit)
|
||||
offset = Keyword.get(opts, :offset, 0)
|
||||
|
||||
for_user = Keyword.get(opts, :for_user)
|
||||
nick_query = explicit_nick && verify_and_normalise_nick(query_string)
|
||||
|
||||
query_string = format_query(query_string)
|
||||
|
||||
# If this returns anything, it should bounce to the top
|
||||
maybe_resolved = maybe_resolve(resolve, for_user, query_string)
|
||||
|
||||
[]
|
||||
|> maybe_add_resolved(maybe_resolved)
|
||||
|> maybe_add_ap_id_match(query_string)
|
||||
|> maybe_add_uri_match(query_string)
|
||||
|> maybe_add_fts_search(query_string, for_user, following, offset, result_limit)
|
||||
end
|
||||
|
||||
defp maybe_add_resolved(list, {:ok, %User{} = user}) do
|
||||
[user.id | list]
|
||||
end
|
||||
|
||||
defp maybe_add_resolved(list, _), do: list
|
||||
|
||||
defp maybe_add_ap_id_match(list, query) do
|
||||
if user = User.get_cached_by_ap_id(query) do
|
||||
[user.id | list]
|
||||
if nick_query do
|
||||
nick_prefix_matches(nick_query, for_user, local_only, following, result_limit, offset)
|
||||
else
|
||||
list
|
||||
fts_matches(query_string, for_user, local_only, following, result_limit, offset)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_uri_match(list, query) do
|
||||
with {:ok, query} <- UriType.cast(query),
|
||||
q = from(u in User, where: u.uri == ^query, select: u.id),
|
||||
users = Pleroma.Repo.all(q) do
|
||||
users ++ list
|
||||
else
|
||||
_ -> list
|
||||
end
|
||||
defp nick_prefix_matches(nick_prefix, for_user, local_only, following, result_limit, offset) do
|
||||
base_query(for_user, following)
|
||||
|> where(
|
||||
[u],
|
||||
fragment(
|
||||
"starts_with(LOWER(?) COLLATE \"C\", LOWER(?::text) COLLATE \"C\")",
|
||||
u.nickname,
|
||||
^nick_prefix
|
||||
)
|
||||
)
|
||||
|> filter_user_query(for_user, local_only)
|
||||
# prefer shorter (more similar) matches and especially prefer if currently matching the full name part
|
||||
|> select_merge(
|
||||
[u],
|
||||
%{
|
||||
search_rank:
|
||||
fragment(
|
||||
"""
|
||||
length(?) / length(?)::float +
|
||||
CASE
|
||||
WHEN ? = ? THEN 1.0
|
||||
WHEN starts_with(LOWER(?) COLLATE "C", LOWER(?::text) COLLATE "C" || '@') THEN 0.5
|
||||
ELSE 0
|
||||
END
|
||||
""",
|
||||
^nick_prefix,
|
||||
u.nickname,
|
||||
^nick_prefix,
|
||||
u.nickname,
|
||||
u.nickname,
|
||||
^nick_prefix
|
||||
)
|
||||
|> selected_as(:search_rank)
|
||||
}
|
||||
)
|
||||
|> order_by([u], desc: selected_as(:search_rank), desc: u.local, asc: u.id)
|
||||
|> Pagination.fetch_paginated(%{"offset" => offset, "limit" => result_limit}, :offset)
|
||||
end
|
||||
|
||||
defp maybe_add_fts_search(top_user_ids, query_string, for_user, following, offset, result_limit) do
|
||||
defp fts_matches(query_string, for_user, local_only, following, result_limit, offset) do
|
||||
gin_limit = Pleroma.Config.get([Pleroma.Search.DatabaseSearch, :gin_fuzzy_search_limit])
|
||||
|
||||
if is_integer(gin_limit) do
|
||||
|
|
@ -66,8 +200,7 @@ defmodule Pleroma.User.Search do
|
|||
# SET LOCAL statement cannot be parametrised it seems; safe because integer
|
||||
Repo.query!("SET LOCAL gin_fuzzy_search_limit TO #{gin_limit}", [])
|
||||
|
||||
{:ok,
|
||||
do_fts_search(top_user_ids, query_string, for_user, following, offset, result_limit)}
|
||||
{:ok, do_fts_search(query_string, for_user, local_only, following, offset, result_limit)}
|
||||
end)
|
||||
|> then(fn
|
||||
{:ok, result} ->
|
||||
|
|
@ -75,106 +208,55 @@ defmodule Pleroma.User.Search do
|
|||
|
||||
error ->
|
||||
Logger.error("#{__MODULE__}: user search transaction failed: #{inspect(error)}")
|
||||
flake_ids = Enum.map(top_user_ids, &FlakeId.from_string(&1))
|
||||
|
||||
from(u in User,
|
||||
inner_lateral_join:
|
||||
i in fragment(
|
||||
"SELECT * FROM UNNEST(?::uuid[]) WITH ORDINALITY AS i(id, ordinality)",
|
||||
^flake_ids
|
||||
),
|
||||
on: u.id == i.id,
|
||||
order_by: [asc: i.ordinality]
|
||||
)
|
||||
|> Repo.all()
|
||||
[]
|
||||
end)
|
||||
else
|
||||
do_fts_search(top_user_ids, query_string, for_user, following, offset, result_limit)
|
||||
do_fts_search(query_string, for_user, local_only, following, offset, result_limit)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_fts_search(top_user_ids, query_string, for_user, following, offset, result_limit) do
|
||||
query_string
|
||||
|> search_query(for_user, following, top_user_ids)
|
||||
defp do_fts_search(query_string, for_user, local_only, following, offset, result_limit) do
|
||||
base_query(for_user, following)
|
||||
|> filter_user_query(for_user, local_only)
|
||||
|> fts_search(query_string)
|
||||
|> trigram_rank(query_string)
|
||||
|> order_by([u], desc: selected_as(:search_rank), desc: u.local, asc: u.id)
|
||||
|> Pagination.fetch_paginated(%{"offset" => offset, "limit" => result_limit}, :offset)
|
||||
end
|
||||
|
||||
def sanitise_domain(domain) do
|
||||
domain
|
||||
|> String.replace(~r/[!-\,|@|?|<|>|[-`|{-~|\/|:|\s]+/, "")
|
||||
end
|
||||
defp base_query(%User{} = user, true), do: User.get_friends_query(user)
|
||||
defp base_query(_user, _following), do: User
|
||||
|
||||
defp format_query(query_string) do
|
||||
# Strip the beginning @ off if there is a query
|
||||
query_string = String.trim_leading(query_string, "@")
|
||||
|
||||
with [name, domain] <- String.split(query_string, "@") do
|
||||
encoded_domain =
|
||||
domain
|
||||
|> sanitise_domain()
|
||||
|> String.to_charlist()
|
||||
|> :idna.encode()
|
||||
|> to_string()
|
||||
|
||||
name <> "@" <> encoded_domain
|
||||
else
|
||||
_ -> query_string
|
||||
end
|
||||
end
|
||||
|
||||
defp search_query(query_string, for_user, following, top_user_ids) do
|
||||
for_user
|
||||
|> base_query(following)
|
||||
|> filter_blocked_user(for_user)
|
||||
defp filter_user_query(query, for_user, local_only) do
|
||||
query
|
||||
|> filter_invisible_users()
|
||||
|> filter_internal_users()
|
||||
|> filter_blocked_domains(for_user)
|
||||
|> fts_search(query_string)
|
||||
|> select_top_users(top_user_ids)
|
||||
|> trigram_rank(query_string)
|
||||
|> boost_search_rank(for_user, top_user_ids)
|
||||
|> subquery()
|
||||
|> order_by(desc: :search_rank)
|
||||
|> maybe_restrict_local(for_user)
|
||||
|> filter_deactivated_users()
|
||||
end
|
||||
|
||||
defp select_top_users(query, top_user_ids) do
|
||||
from(u in query,
|
||||
or_where: u.id in ^top_user_ids
|
||||
)
|
||||
|> filter_blocked_user(for_user)
|
||||
|> filter_blocked_domains(for_user)
|
||||
|> maybe_restrict_local(local_only)
|
||||
end
|
||||
|
||||
defp fts_search(query, query_string) do
|
||||
query_string = to_tsquery(query_string)
|
||||
|
||||
from(
|
||||
u in query,
|
||||
where:
|
||||
fragment(
|
||||
# The fragment must _exactly_ match `users_fts_index`, otherwise the index won't work
|
||||
# The ts_vector and LOWER expression must exactly match the indexes
|
||||
# (only the collation _inisde_ the LOWER call is relevant, the outer part just ensures we have
|
||||
# a deterministic collation supported by starts_with and allowing fast byte comparisons)
|
||||
"""
|
||||
(
|
||||
setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') ||
|
||||
setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B')
|
||||
) @@ to_tsquery('simple', ?)
|
||||
starts_with(LOWER(?) COLLATE "C", LOWER(?::text) COLLATE "C") OR
|
||||
to_tsvector('simple', ?) @@ plainto_tsquery('simple', ?::text)
|
||||
""",
|
||||
u.nickname,
|
||||
^query_string,
|
||||
u.name,
|
||||
^query_string
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp to_tsquery(query_string) do
|
||||
String.trim_trailing(query_string, "@" <> local_domain())
|
||||
|> String.replace(~r/[!-\/|@|[-`|{-~|:-?]+/, " ")
|
||||
|> String.trim()
|
||||
|> String.split()
|
||||
|> Enum.map(&(&1 <> ":*"))
|
||||
|> Enum.join(" | ")
|
||||
end
|
||||
|
||||
# Considers nickname match, localized nickname match, name match; preferences nickname match
|
||||
defp trigram_rank(query, query_string) do
|
||||
from(
|
||||
|
|
@ -194,13 +276,11 @@ defmodule Pleroma.User.Search do
|
|||
^query_string,
|
||||
u.name
|
||||
)
|
||||
|> selected_as(:search_rank)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
defp base_query(%User{} = user, true), do: User.get_friends_query(user)
|
||||
defp base_query(_user, _following), do: User
|
||||
|
||||
defp filter_invisible_users(query) do
|
||||
from(q in query, where: q.invisible == false)
|
||||
end
|
||||
|
|
@ -230,79 +310,27 @@ defmodule Pleroma.User.Search do
|
|||
|
||||
from(
|
||||
q in query,
|
||||
where: fragment("substring(ap_id from '.*://([^/]*)') NOT IN (?)", ^domains)
|
||||
where: fragment("split_part(ap_id, '/', 3) NOT IN (?)", ^domains)
|
||||
)
|
||||
end
|
||||
|
||||
defp filter_blocked_domains(query, _), do: query
|
||||
|
||||
defp maybe_resolve(true, user, query) do
|
||||
case {limit(), user} do
|
||||
{:all, _} -> :noop
|
||||
{:unauthenticated, %User{}} -> User.get_or_fetch(query)
|
||||
{:unauthenticated, _} -> :noop
|
||||
{false, _} -> User.get_or_fetch(query)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_resolve(_, _, _), do: :noop
|
||||
|
||||
defp maybe_restrict_local(q, user) do
|
||||
case {limit(), user} do
|
||||
{:all, _} -> restrict_local(q)
|
||||
{:unauthenticated, %User{}} -> q
|
||||
{:unauthenticated, _} -> restrict_local(q)
|
||||
{false, _} -> q
|
||||
end
|
||||
end
|
||||
|
||||
defp limit, do: Pleroma.Config.get([:instance, :limit_to_local_content], :unauthenticated)
|
||||
|
||||
defp should_restrict_local(user) do
|
||||
case {limit(), user} do
|
||||
{:all, _} -> true
|
||||
{:unauthenticated, %User{}} -> false
|
||||
{:unauthenticated, _} -> true
|
||||
{false, _} -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_restrict_local(q, true), do: restrict_local(q)
|
||||
defp maybe_restrict_local(q, false), do: q
|
||||
|
||||
defp restrict_local(q), do: where(q, [u], u.local == true)
|
||||
|
||||
defp local_domain, do: Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host])
|
||||
|
||||
defp boost_search_rank(query, %User{} = for_user, top_user_ids) do
|
||||
friends_ids = User.get_friends_ids(for_user)
|
||||
followers_ids = User.get_followers_ids(for_user)
|
||||
|
||||
from(u in subquery(query),
|
||||
select_merge: %{
|
||||
search_rank:
|
||||
fragment(
|
||||
"""
|
||||
CASE WHEN (?) THEN (?) * 1.5
|
||||
WHEN (?) THEN (?) * 1.3
|
||||
WHEN (?) THEN (?) * 1.1
|
||||
WHEN (?) THEN 9001
|
||||
ELSE (?) END
|
||||
""",
|
||||
u.id in ^friends_ids and u.id in ^followers_ids,
|
||||
u.search_rank,
|
||||
u.id in ^friends_ids,
|
||||
u.search_rank,
|
||||
u.id in ^followers_ids,
|
||||
u.search_rank,
|
||||
u.id in ^top_user_ids,
|
||||
u.search_rank
|
||||
)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
defp boost_search_rank(query, _for_user, top_user_ids) do
|
||||
from(u in subquery(query),
|
||||
select_merge: %{
|
||||
search_rank:
|
||||
fragment(
|
||||
"""
|
||||
CASE WHEN (?) THEN 9001
|
||||
ELSE (?) END
|
||||
""",
|
||||
u.id in ^top_user_ids,
|
||||
u.search_rank
|
||||
)
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ defmodule Pleroma.Web.AdminAPI.Search do
|
|||
def user(params \\ %{}) do
|
||||
query =
|
||||
params
|
||||
|> Map.drop([:page, :page_size])
|
||||
|> Map.drop([:page, :page_size, :query])
|
||||
|> Map.put(:invisible, false)
|
||||
|> Map.put(:nickname_substr, params[:query])
|
||||
|> User.Query.build()
|
||||
|> order_by(desc: :id)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@
|
|||
defmodule Pleroma.Web.MastodonAPI.SearchController do
|
||||
use Pleroma.Web, :controller
|
||||
|
||||
alias Pleroma.Config
|
||||
alias Pleroma.Repo
|
||||
alias Pleroma.User
|
||||
alias Pleroma.User.Search, as: UserSearch
|
||||
alias Pleroma.Web.ControllerHelper
|
||||
alias Pleroma.Web.Endpoint
|
||||
alias Pleroma.Web.MastodonAPI.AccountView
|
||||
|
|
@ -29,8 +31,24 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
|
|||
|
||||
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation
|
||||
|
||||
defp do_if_allowed(%{assigns: %{user: user}} = conn, action) do
|
||||
restrict = Config.restrict_unauthenticated_access?(:search, :all)
|
||||
|
||||
if !!user || !restrict do
|
||||
action.(conn)
|
||||
else
|
||||
conn
|
||||
|> render_error(:forbidden, "This resource requires authentication.")
|
||||
end
|
||||
end
|
||||
|
||||
def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do
|
||||
accounts = User.search(query, search_options(params, user))
|
||||
conn
|
||||
|> do_if_allowed(&do_account_search(&1, user, query, params))
|
||||
end
|
||||
|
||||
defp do_account_search(conn, user, query, params) do
|
||||
accounts = UserSearch.search(query, search_options(params, user))
|
||||
|
||||
conn
|
||||
|> put_view(AccountView)
|
||||
|
|
@ -41,7 +59,10 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
|
|||
)
|
||||
end
|
||||
|
||||
def search2(conn, params), do: do_search(:v2, conn, params)
|
||||
def search2(conn, params) do
|
||||
conn
|
||||
|> do_if_allowed(&do_search(:v2, &1, params))
|
||||
end
|
||||
|
||||
defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do
|
||||
query = String.trim(query)
|
||||
|
|
@ -73,12 +94,29 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
|
|||
json(conn, result)
|
||||
end
|
||||
|
||||
def should_restrict_to_local(user) do
|
||||
limit = Pleroma.Config.get([:instance, :limit_to_local_content], :unauthenticated)
|
||||
|
||||
case {limit, user} do
|
||||
{:all, _} -> true
|
||||
{:unauthenticated, %User{}} -> false
|
||||
{:unauthenticated, _} -> true
|
||||
{false, _} -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp search_options(params, user) do
|
||||
conf_resolve = Config.restrict_unauthenticated_access?(:search, :resolve)
|
||||
allow_resolve = !should_restrict_to_local(user) && (!!user || !conf_resolve)
|
||||
|
||||
conf_paginate = Config.restrict_unauthenticated_access?(:search, :paginate)
|
||||
allow_paginate = !!user || !conf_paginate
|
||||
|
||||
[
|
||||
resolve: params[:resolve],
|
||||
resolve: allow_resolve && params[:resolve],
|
||||
following: params[:following],
|
||||
limit: min(params[:limit], @search_limit),
|
||||
offset: params[:offset],
|
||||
offset: (allow_paginate && params[:offset]) || 0,
|
||||
type: params[:type],
|
||||
author: get_author(params),
|
||||
embed_relationships: ControllerHelper.embed_relationships?(params),
|
||||
|
|
@ -88,7 +126,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
|
|||
end
|
||||
|
||||
defp resource_search(_, "accounts", query, options) do
|
||||
accounts = with_fallback(fn -> User.search(query, options) end)
|
||||
accounts = with_fallback(fn -> UserSearch.search(query, options) end)
|
||||
|
||||
AccountView.render("index.json",
|
||||
users: accounts,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ defmodule Pleroma.Repo.Migrations.AddFTSIndexToActivities do
|
|||
|
||||
def change do
|
||||
create(
|
||||
index(:activities, ["(to_tsvector('english', data->'object'->>'content'))"],
|
||||
index(:activities, ["(to_tsvector('simple', data->'object'->>'content'))"],
|
||||
using: :gin,
|
||||
name: :activities_fts
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ defmodule Pleroma.Repo.Migrations.AddFTSIndexToObjects do
|
|||
|
||||
def change do
|
||||
drop_if_exists(
|
||||
index(:activities, ["(to_tsvector('english', data->'object'->>'content'))"],
|
||||
index(:activities, ["(to_tsvector('simple', data->'object'->>'content'))"],
|
||||
using: :gin,
|
||||
name: :activities_fts
|
||||
)
|
||||
)
|
||||
|
||||
create_if_not_exists(
|
||||
index(:objects, ["(to_tsvector('english', data->>'content'))"],
|
||||
index(:objects, ["(to_tsvector('simple', data->>'content'))"],
|
||||
using: :gin,
|
||||
name: :objects_fts
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule Pleroma.Repo.Migrations.AddDefaultTextSearchConfig do
|
|||
def change do
|
||||
execute("DO $$
|
||||
BEGIN
|
||||
execute 'ALTER DATABASE \"'||current_database()||'\" SET default_text_search_config = ''english'' ';
|
||||
execute 'ALTER DATABASE \"'||current_database()||'\" SET default_text_search_config = ''simple'' ';
|
||||
END
|
||||
$$;")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
defmodule Pleroma.Repo.Migrations.UpdateObjectFTSIndex do
|
||||
use Ecto.Migration
|
||||
|
||||
# copied from 20190501125843_add_fts_index_to_objects
|
||||
# but with parametrised current search config
|
||||
defmacro old_index(search_config) do
|
||||
quote do
|
||||
index(:objects, ["(to_tsvector('#{unquote(search_config)}'::regconfig, data->>'content'))"],
|
||||
using: :gin,
|
||||
name: :objects_fts
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defmacro new_index(search_config) do
|
||||
quote do
|
||||
index(
|
||||
:objects,
|
||||
[
|
||||
"""
|
||||
(to_tsvector(
|
||||
'#{unquote(search_config)}'::regconfig,
|
||||
COALESCE(data->>'summary', '') || ' ' || (data->>'content')
|
||||
))
|
||||
"""
|
||||
],
|
||||
using: :gin,
|
||||
name: :objects_fts
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def change() do
|
||||
%{rows: [[tsc]]} =
|
||||
Pleroma.Repo.query!("select current_setting('default_text_search_config')::regconfig;")
|
||||
|
||||
drop_if_exists(old_index(tsc))
|
||||
create_if_not_exists(new_index(tsc))
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
defmodule Pleroma.Repo.Migrations.UpdateUserSearchIndexes do
|
||||
use Ecto.Migration
|
||||
|
||||
# copied from 20190115085500_create_user_fts_index
|
||||
@old_fts_idx index(
|
||||
:users,
|
||||
[
|
||||
"""
|
||||
(setweight(to_tsvector('simple', regexp_replace(nickname, '\\W', ' ', 'g')), 'A') ||
|
||||
setweight(to_tsvector('simple', regexp_replace(coalesce(name, ''), '\\W', ' ', 'g')), 'B'))
|
||||
"""
|
||||
],
|
||||
name: :users_fts_index,
|
||||
using: :gin
|
||||
)
|
||||
|
||||
@new_fts_idx index(
|
||||
:users,
|
||||
["to_tsvector('simple', name)"],
|
||||
name: :users_displayname_fts_index,
|
||||
using: :gin
|
||||
)
|
||||
|
||||
# Conceptually this _could_ replace the existing unique nickname index, since citext is case-insesensitive anyway.
|
||||
# In practice however, we need this in the first place, beacuse PostgreSQL does not provide case-insensitive starts_with for citext and
|
||||
# also couldn’t use an index with explicit LOWER() for regular nickname lookups without, eventhough it ought to be the same for a citext-typed column
|
||||
#
|
||||
# Once we raise our minimal PostgreSQL version to 18, we may want to recreate this index with CASEFOLD instead
|
||||
# (and adapt all queries to match ofc)
|
||||
@prefix_idx index(
|
||||
:users,
|
||||
[~s'LOWER(nickname)'],
|
||||
name: :users_casefolded_nickname_index,
|
||||
unique: true,
|
||||
using: :btree
|
||||
)
|
||||
|
||||
def change() do
|
||||
drop_if_exists(@old_fts_idx)
|
||||
create_if_not_exists(@new_fts_idx)
|
||||
create_if_not_exists(@prefix_idx)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
defmodule Pleroma.Repo.Migrations.DropPlainUsersNicknameIndex do
|
||||
use Ecto.Migration
|
||||
|
||||
def change() do
|
||||
# redundant with the explicitly case-folded index
|
||||
drop_if_exists(unique_index(:users, [:nickname], name: :users_nickname_index))
|
||||
end
|
||||
end
|
||||
|
|
@ -1,15 +1,31 @@
|
|||
defmodule Pleroma.Repo.Migrations.AddFtsIndexToObjectsTwo do
|
||||
use Ecto.Migration
|
||||
|
||||
defmacro gin_index(search_config) do
|
||||
quote do
|
||||
index(
|
||||
:objects,
|
||||
[
|
||||
"""
|
||||
(to_tsvector(
|
||||
'#{unquote(search_config)}'::regconfig,
|
||||
COALESCE(data->>'summary', '') || ' ' || (data->>'content')
|
||||
))
|
||||
"""
|
||||
],
|
||||
using: :gin,
|
||||
name: :objects_fts
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def up do
|
||||
execute("create extension if not exists rum")
|
||||
|
||||
drop_if_exists(
|
||||
index(:objects, ["(to_tsvector('english', data->>'content'))"],
|
||||
using: :gin,
|
||||
name: :objects_fts
|
||||
)
|
||||
)
|
||||
%{rows: [[tsc]]} =
|
||||
Pleroma.Repo.query!("select current_setting('default_text_search_config')::regconfig;")
|
||||
|
||||
drop_if_exists(gin_index(tsc))
|
||||
|
||||
alter table(:objects) do
|
||||
add(:fts_content, :tsvector)
|
||||
|
|
@ -17,7 +33,7 @@ defmodule Pleroma.Repo.Migrations.AddFtsIndexToObjectsTwo do
|
|||
|
||||
execute("CREATE FUNCTION objects_fts_update() RETURNS trigger AS $$
|
||||
begin
|
||||
new.fts_content := to_tsvector(new.data->>'content');
|
||||
new.fts_content := to_tsvector(COALESCE(new.data->>'summary', '') || ' ' || (new.data->>'content'));
|
||||
return new;
|
||||
end
|
||||
$$ LANGUAGE plpgsql")
|
||||
|
|
@ -41,11 +57,9 @@ defmodule Pleroma.Repo.Migrations.AddFtsIndexToObjectsTwo do
|
|||
remove(:fts_content, :tsvector)
|
||||
end
|
||||
|
||||
create_if_not_exists(
|
||||
index(:objects, ["(to_tsvector('english', data->>'content'))"],
|
||||
using: :gin,
|
||||
name: :objects_fts
|
||||
)
|
||||
)
|
||||
%{rows: [[tsc]]} =
|
||||
Pleroma.Repo.query!("select current_setting('default_text_search_config')::regconfig;")
|
||||
|
||||
create_if_not_exists(gin_index(tsc))
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
defmodule Pleroma.Repo.Migrations.UpdateObjectFTSIndex do
|
||||
use Ecto.Migration
|
||||
|
||||
def change(), do: :ok
|
||||
end
|
||||
|
|
@ -502,22 +502,16 @@ defmodule Mix.Tasks.Pleroma.UserTest do
|
|||
|
||||
describe "search" do
|
||||
test "it returns users matching" do
|
||||
user = insert(:user)
|
||||
moon = insert(:user, nickname: "moon", name: "fediverse expert moon")
|
||||
moot = insert(:user, nickname: "moot")
|
||||
kawen = insert(:user, nickname: "kawen", name: "fediverse expert moon")
|
||||
|
||||
{:ok, user, moon} = User.follow(user, moon)
|
||||
|
||||
assert [moon.id, kawen.id] == User.Search.search("moon") |> Enum.map(& &1.id)
|
||||
|
||||
res = User.search("moo") |> Enum.map(& &1.id)
|
||||
assert Enum.sort([moon.id, moot.id, kawen.id]) == Enum.sort(res)
|
||||
res = User.Search.search("moo") |> Enum.map(& &1.id)
|
||||
assert Enum.sort([moon.id, moot.id]) == Enum.sort(res)
|
||||
|
||||
assert [kawen.id, moon.id] == User.Search.search("expert fediverse") |> Enum.map(& &1.id)
|
||||
|
||||
assert [moon.id, kawen.id] ==
|
||||
User.Search.search("expert fediverse", for_user: user) |> Enum.map(& &1.id)
|
||||
assert [moon.id, kawen.id] == User.Search.search("expert fediverse") |> Enum.map(& &1.id)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ defmodule Pleroma.ActivityTest do
|
|||
alias Pleroma.Activity
|
||||
alias Pleroma.Bookmark
|
||||
alias Pleroma.Object
|
||||
alias Pleroma.Tests.ObanHelpers
|
||||
alias Pleroma.ThreadMute
|
||||
import Pleroma.Factory
|
||||
|
||||
|
|
@ -130,83 +129,6 @@ defmodule Pleroma.ActivityTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "search" do
|
||||
setup do
|
||||
user = insert(:user)
|
||||
|
||||
params = %{
|
||||
"@context" => "https://www.w3.org/ns/activitystreams",
|
||||
"actor" => "http://mastodon.example.org/users/admin",
|
||||
"type" => "Create",
|
||||
"id" => "http://mastodon.example.org/users/admin/activities/1",
|
||||
"object" => %{
|
||||
"type" => "Note",
|
||||
"content" => "find me!",
|
||||
"id" => "http://mastodon.example.org/users/admin/objects/1",
|
||||
"attributedTo" => "http://mastodon.example.org/users/admin",
|
||||
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
|
||||
},
|
||||
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
|
||||
}
|
||||
|
||||
{:ok, local_activity} = Pleroma.Web.CommonAPI.post(user, %{status: "find me!"})
|
||||
{:ok, japanese_activity} = Pleroma.Web.CommonAPI.post(user, %{status: "更新情報"})
|
||||
{:ok, job} = Pleroma.Web.Federator.incoming_ap_doc(params)
|
||||
{:ok, remote_activity} = ObanHelpers.perform(job)
|
||||
remote_activity = Activity.get_by_id_with_object(remote_activity.id)
|
||||
|
||||
%{
|
||||
japanese_activity: japanese_activity,
|
||||
local_activity: local_activity,
|
||||
remote_activity: remote_activity,
|
||||
user: user
|
||||
}
|
||||
end
|
||||
|
||||
setup do: clear_config([:instance, :limit_to_local_content])
|
||||
|
||||
test "finds utf8 text in statuses", %{
|
||||
japanese_activity: japanese_activity,
|
||||
user: user
|
||||
} do
|
||||
activities = Activity.search(user, "更新情報")
|
||||
|
||||
assert [^japanese_activity] = activities
|
||||
end
|
||||
|
||||
test "find local and remote statuses for authenticated users", %{
|
||||
local_activity: local_activity,
|
||||
remote_activity: remote_activity,
|
||||
user: user
|
||||
} do
|
||||
activities = Enum.sort_by(Activity.search(user, "find me"), & &1.id)
|
||||
|
||||
assert [^local_activity, ^remote_activity] = activities
|
||||
end
|
||||
|
||||
test "find only local statuses for unauthenticated users", %{local_activity: local_activity} do
|
||||
assert [^local_activity] = Activity.search(nil, "find me")
|
||||
end
|
||||
|
||||
test "find only local statuses for unauthenticated users when `limit_to_local_content` is `:all`",
|
||||
%{local_activity: local_activity} do
|
||||
clear_config([:instance, :limit_to_local_content], :all)
|
||||
assert [^local_activity] = Activity.search(nil, "find me")
|
||||
end
|
||||
|
||||
test "find all statuses for unauthenticated users when `limit_to_local_content` is `false`",
|
||||
%{
|
||||
local_activity: local_activity,
|
||||
remote_activity: remote_activity
|
||||
} do
|
||||
clear_config([:instance, :limit_to_local_content], false)
|
||||
|
||||
activities = Enum.sort_by(Activity.search(nil, "find me"), & &1.id)
|
||||
|
||||
assert [^local_activity, ^remote_activity] = activities
|
||||
end
|
||||
end
|
||||
|
||||
test "all_by_ids_with_object/1" do
|
||||
%{id: id1} = insert(:note_activity)
|
||||
%{id: id2} = insert(:note_activity)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.Search.DatabaseSearchTest do
|
||||
alias Pleroma.Activity
|
||||
alias Pleroma.Search.DatabaseSearch
|
||||
alias Pleroma.Web.CommonAPI
|
||||
alias Pleroma.Tests.ObanHelpers
|
||||
import Pleroma.Factory
|
||||
|
||||
use Pleroma.DataCase, async: false
|
||||
|
|
@ -38,4 +40,95 @@ defmodule Pleroma.Search.DatabaseSearchTest do
|
|||
|
||||
assert result.id == other_post.id
|
||||
end
|
||||
|
||||
describe "search post content" do
|
||||
setup do
|
||||
Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
|
||||
|
||||
user = insert(:user)
|
||||
|
||||
params = %{
|
||||
"@context" => "https://www.w3.org/ns/activitystreams",
|
||||
"actor" => "http://mastodon.example.org/users/admin",
|
||||
"type" => "Create",
|
||||
"id" => "http://mastodon.example.org/users/admin/activities/1",
|
||||
"object" => %{
|
||||
"type" => "Note",
|
||||
"content" => "find me!",
|
||||
"id" => "http://mastodon.example.org/users/admin/objects/1",
|
||||
"attributedTo" => "http://mastodon.example.org/users/admin",
|
||||
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
|
||||
},
|
||||
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
|
||||
}
|
||||
|
||||
{:ok, local_activity} = Pleroma.Web.CommonAPI.post(user, %{status: "find me!"})
|
||||
{:ok, japanese_activity} = Pleroma.Web.CommonAPI.post(user, %{status: "更新情報"})
|
||||
{:ok, job} = Pleroma.Web.Federator.incoming_ap_doc(params)
|
||||
{:ok, remote_activity} = ObanHelpers.perform(job)
|
||||
remote_activity = Activity.get_by_id_with_object(remote_activity.id)
|
||||
|
||||
%{
|
||||
japanese_activity: japanese_activity,
|
||||
local_activity: local_activity,
|
||||
remote_activity: remote_activity,
|
||||
user: user
|
||||
}
|
||||
end
|
||||
|
||||
setup do: clear_config([:instance, :limit_to_local_content])
|
||||
|
||||
test "finds utf8 text in statuses", %{
|
||||
japanese_activity: japanese_activity,
|
||||
user: user
|
||||
} do
|
||||
activities = DatabaseSearch.search(user, "更新情報")
|
||||
|
||||
assert [^japanese_activity] = activities
|
||||
end
|
||||
|
||||
test "finds post via content warning", %{user: user} do
|
||||
{:ok, activity} =
|
||||
Pleroma.Web.CommonAPI.post(user, %{
|
||||
status: "bug friend",
|
||||
spoiler_text: "closeup of very large bug"
|
||||
})
|
||||
|
||||
activities = DatabaseSearch.search(user, "\"very large bug\"")
|
||||
|
||||
assert [^activity] = activities
|
||||
end
|
||||
|
||||
test "find local and remote statuses for authenticated users", %{
|
||||
local_activity: local_activity,
|
||||
remote_activity: remote_activity,
|
||||
user: user
|
||||
} do
|
||||
activities = Enum.sort_by(DatabaseSearch.search(user, "find me"), & &1.id)
|
||||
|
||||
assert [^local_activity, ^remote_activity] = activities
|
||||
end
|
||||
|
||||
test "find only local statuses for unauthenticated users", %{local_activity: local_activity} do
|
||||
assert [^local_activity] = DatabaseSearch.search(nil, "find me")
|
||||
end
|
||||
|
||||
test "find only local statuses for unauthenticated users when `limit_to_local_content` is `:all`",
|
||||
%{local_activity: local_activity} do
|
||||
clear_config([:instance, :limit_to_local_content], :all)
|
||||
assert [^local_activity] = DatabaseSearch.search(nil, "find me")
|
||||
end
|
||||
|
||||
test "find all statuses for unauthenticated users when `limit_to_local_content` is `false`",
|
||||
%{
|
||||
local_activity: local_activity,
|
||||
remote_activity: remote_activity
|
||||
} do
|
||||
clear_config([:instance, :limit_to_local_content], false)
|
||||
|
||||
activities = Enum.sort_by(DatabaseSearch.search(nil, "find me"), & &1.id)
|
||||
|
||||
assert [^local_activity, ^remote_activity] = activities
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,22 +1,382 @@
|
|||
defmodule Pleroma.User.SearchTest do
|
||||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.UserSearchTest do
|
||||
alias Pleroma.User
|
||||
alias Pleroma.User.Search
|
||||
use Pleroma.DataCase
|
||||
|
||||
describe "sanitise_domain/1" do
|
||||
test "should remove url-reserved characters" do
|
||||
examples = [
|
||||
["example.com", "example.com"],
|
||||
["no spaces", "nospaces"],
|
||||
["no@at", "noat"],
|
||||
["dash-is-ok", "dash-is-ok"],
|
||||
["underscore_not_so_much", "underscorenotsomuch"],
|
||||
["no!", "no"],
|
||||
["no?", "no"],
|
||||
["a$b%s^o*l(u)t'e#l<y n>o/t", "absolutelynot"]
|
||||
]
|
||||
import Pleroma.Factory
|
||||
|
||||
for [input, expected] <- examples do
|
||||
assert Pleroma.User.Search.sanitise_domain(input) == expected
|
||||
end
|
||||
setup_all do
|
||||
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "User.search" do
|
||||
setup do: clear_config([:instance, :limit_to_local_content])
|
||||
|
||||
test "returns a resolved user as the first and only result" do
|
||||
clear_config([:instance, :limit_to_local_content], false)
|
||||
user = insert(:user, %{nickname: "no_relation", ap_id: "https://lain.com/users/lain"})
|
||||
_user = insert(:user, %{nickname: "com_user"})
|
||||
|
||||
[first_user] = Search.search("https://lain.com/users/lain", resolve: true)
|
||||
|
||||
assert first_user.id == user.id
|
||||
end
|
||||
|
||||
test "returns a user with matching ap_id as the first and only result" do
|
||||
user = insert(:user, %{nickname: "no_relation", ap_id: "https://lain.com/users/lain"})
|
||||
_user = insert(:user, %{nickname: "com_user"})
|
||||
|
||||
[first_user] = Search.search("https://lain.com/users/lain")
|
||||
|
||||
assert first_user.id == user.id
|
||||
end
|
||||
|
||||
test "doesn't die if two users have the same uri" do
|
||||
insert(:user, %{uri: "https://gensokyo.2hu/@raymoo"})
|
||||
insert(:user, %{uri: "https://gensokyo.2hu/@raymoo"})
|
||||
assert [_first_user, _second_user] = Search.search("https://gensokyo.2hu/@raymoo")
|
||||
end
|
||||
|
||||
test "returns a user with matching uri as the first and only result" do
|
||||
user =
|
||||
insert(:user, %{
|
||||
nickname: "no_relation",
|
||||
ap_id: "https://lain.com/users/lain",
|
||||
uri: "https://lain.com/@lain"
|
||||
})
|
||||
|
||||
_user = insert(:user, %{nickname: "com_user"})
|
||||
|
||||
[first_user] = Search.search("https://lain.com/@lain")
|
||||
|
||||
assert first_user.id == user.id
|
||||
end
|
||||
|
||||
test "excludes invisible users from results" do
|
||||
user = insert(:user, %{nickname: "john t1000"})
|
||||
insert(:user, %{invisible: true, nickname: "john t800"})
|
||||
|
||||
[found_user] = Search.search("john")
|
||||
assert found_user.id == user.id
|
||||
end
|
||||
|
||||
test "excludes deactivated users from results" do
|
||||
user = insert(:user, %{nickname: "john t1000"})
|
||||
insert(:user, %{is_active: false, nickname: "john t800"})
|
||||
|
||||
[found_user] = Search.search("john")
|
||||
assert found_user.id == user.id
|
||||
end
|
||||
|
||||
# Note: as in Mastodon, `is_discoverable` doesn't anyhow relate to user searchability
|
||||
test "includes non-discoverable users in results" do
|
||||
insert(:user, %{nickname: "john 3000", is_discoverable: false})
|
||||
insert(:user, %{nickname: "john 3001"})
|
||||
|
||||
users = Search.search("john")
|
||||
assert Enum.count(users) == 2
|
||||
end
|
||||
|
||||
test "excludes service actors from results" do
|
||||
insert(:user, actor_type: "Application", nickname: "user1")
|
||||
service = insert(:user, actor_type: "Service", nickname: "user2")
|
||||
person = insert(:user, actor_type: "Person", nickname: "user3")
|
||||
|
||||
assert [found_user1, found_user2] = Search.search("user")
|
||||
assert [found_user1.id, found_user2.id] -- [service.id, person.id] == []
|
||||
end
|
||||
|
||||
test "accepts limit parameter" do
|
||||
Enum.each(0..4, &insert(:user, %{nickname: "john#{&1}"}))
|
||||
assert length(Search.search("john", limit: 3)) == 3
|
||||
assert length(Search.search("john")) == 5
|
||||
end
|
||||
|
||||
test "accepts offset parameter" do
|
||||
Enum.each(0..4, &insert(:user, %{nickname: "john#{&1}"}))
|
||||
assert length(Search.search("john", limit: 3)) == 3
|
||||
assert length(Search.search("john", limit: 3, offset: 3)) == 2
|
||||
end
|
||||
|
||||
defp clear_virtual_fields(user) do
|
||||
Map.merge(user, %{search_rank: nil, search_type: nil})
|
||||
end
|
||||
|
||||
test "finds a user by full nickname or its leading fragment" do
|
||||
user = insert(:user, %{nickname: "john"})
|
||||
|
||||
Enum.each(["john", "jo", "j"], fn query ->
|
||||
assert user ==
|
||||
Search.search(query)
|
||||
|> List.first()
|
||||
|> clear_virtual_fields()
|
||||
end)
|
||||
end
|
||||
|
||||
test "doesn't explode if GIN fuzzy limit is set" do
|
||||
clear_config([Pleroma.Search.DatabaseSearch, :gin_fuzzy_search_limit], 10_000)
|
||||
|
||||
user = insert(:user, %{nickname: "john"})
|
||||
|
||||
Enum.each(["john", "jo", "j"], fn query ->
|
||||
assert user ==
|
||||
Search.search(query)
|
||||
|> List.first()
|
||||
|> clear_virtual_fields()
|
||||
end)
|
||||
end
|
||||
|
||||
test "finds a user by full name or fragment(s) of its normalised words" do
|
||||
user = insert(:user, %{name: "John Doe"})
|
||||
|
||||
Enum.each(["John Doe", "JOHN", "doe", "doe..."], fn query ->
|
||||
assert user ==
|
||||
Search.search(query)
|
||||
|> List.first()
|
||||
|> clear_virtual_fields()
|
||||
end)
|
||||
end
|
||||
|
||||
test "limits matching to nicknames for an explicit nickname query" do
|
||||
_user_name = insert(:user, %{name: "ocelot", nickname: "cat@sa.example"})
|
||||
user_nick = insert(:user, %{name: "cat :3", nickname: "ocelot@sa.example"})
|
||||
user_nick2 = insert(:user, %{name: "miaow", nickname: "ocelot2345@sa.example"})
|
||||
|
||||
[result1, result2] = Search.search("@ocelot")
|
||||
assert result1.id == user_nick.id
|
||||
assert result2.id == user_nick2.id
|
||||
end
|
||||
|
||||
test "ranks full nickname match higher than full name match" do
|
||||
nicknamed_user = insert(:user, %{nickname: "hj@shigusegubu.club"})
|
||||
named_user = insert(:user, %{nickname: "xyz@sample.com", name: "HJ"})
|
||||
|
||||
results = Search.search("hj")
|
||||
|
||||
assert [nicknamed_user.id, named_user.id] == Enum.map(results, & &1.id)
|
||||
assert Enum.at(results, 0).search_rank > Enum.at(results, 1).search_rank
|
||||
end
|
||||
|
||||
test "finds users, considering density of matched tokens" do
|
||||
u1 = insert(:user, %{name: "Bar Bar plus Word Word"})
|
||||
u2 = insert(:user, %{name: "Word Word Bar Bar Bar"})
|
||||
|
||||
assert [u2.id, u1.id] == Enum.map(Search.search("bar word"), & &1.id)
|
||||
end
|
||||
|
||||
test "finds users, boosting ranks of friends and followers" do
|
||||
u1 = insert(:user)
|
||||
u2 = insert(:user, %{name: "Doe"})
|
||||
follower = insert(:user, %{name: "Doe"})
|
||||
friend = insert(:user, %{name: "Doe"})
|
||||
|
||||
{:ok, follower, u1} = User.follow(follower, u1)
|
||||
{:ok, u1, friend} = User.follow(u1, friend)
|
||||
|
||||
assert [friend.id, follower.id, u2.id] --
|
||||
Enum.map(Search.search("doe", resolve: false, for_user: u1), & &1.id) == []
|
||||
end
|
||||
|
||||
test "finds followings of user by partial name" do
|
||||
lizz = insert(:user, %{name: "Lizz"})
|
||||
jimi = insert(:user, %{name: "Jimi"})
|
||||
following_lizz = insert(:user, %{name: "Jimi Hendrix"})
|
||||
following_jimi = insert(:user, %{name: "Lizz Wright"})
|
||||
follower_lizz = insert(:user, %{name: "Jimi"})
|
||||
|
||||
{:ok, lizz, following_lizz} = User.follow(lizz, following_lizz)
|
||||
{:ok, _jimi, _following_jimi} = User.follow(jimi, following_jimi)
|
||||
{:ok, _follower_lizz, _lizz} = User.follow(follower_lizz, lizz)
|
||||
|
||||
assert Enum.map(Search.search("jimi", following: true, for_user: lizz), & &1.id) == [
|
||||
following_lizz.id
|
||||
]
|
||||
|
||||
assert Search.search("lizz", following: true, for_user: lizz) == []
|
||||
end
|
||||
|
||||
test "find local and remote users for authenticated users" do
|
||||
u1 = insert(:user, %{name: "lain"})
|
||||
u2 = insert(:user, %{name: "ebn", nickname: "lain@mastodon.social", local: false})
|
||||
u3 = insert(:user, %{nickname: "lain@pleroma.soykaf.com", local: false})
|
||||
|
||||
results =
|
||||
"lain"
|
||||
|> Search.search(for_user: u1)
|
||||
|> Enum.map(& &1.id)
|
||||
|> Enum.sort()
|
||||
|
||||
assert [u1.id, u2.id, u3.id] == results
|
||||
end
|
||||
|
||||
test "find only local users for unauthenticated users" do
|
||||
%{id: id} = insert(:user, %{name: "lain"})
|
||||
insert(:user, %{name: "ebn", nickname: "lain@mastodon.social", local: false})
|
||||
insert(:user, %{nickname: "lain@pleroma.soykaf.com", local: false})
|
||||
|
||||
assert [%{id: ^id}] = Search.search("lain")
|
||||
end
|
||||
|
||||
test "find only local users for authenticated users when `limit_to_local_content` is `:all`" do
|
||||
clear_config([:instance, :limit_to_local_content], :all)
|
||||
|
||||
%{id: id} = insert(:user, %{name: "lain"})
|
||||
insert(:user, %{name: "ebn", nickname: "lain@mastodon.social", local: false})
|
||||
insert(:user, %{nickname: "lain@pleroma.soykaf.com", local: false})
|
||||
|
||||
assert [%{id: ^id}] = Search.search("lain")
|
||||
end
|
||||
|
||||
test "find all users for unauthenticated users when `limit_to_local_content` is `false`" do
|
||||
clear_config([:instance, :limit_to_local_content], false)
|
||||
|
||||
u1 = insert(:user, %{name: "lain"})
|
||||
u2 = insert(:user, %{name: "ebn", nickname: "lain@mastodon.social", local: false})
|
||||
u3 = insert(:user, %{nickname: "lain@pleroma.soykaf.com", local: false})
|
||||
|
||||
results =
|
||||
"lain"
|
||||
|> Search.search()
|
||||
|> Enum.map(& &1.id)
|
||||
|> Enum.sort()
|
||||
|
||||
assert [u1.id, u2.id, u3.id] == results
|
||||
end
|
||||
|
||||
test "does not yield false-positive matches" do
|
||||
insert(:user, %{name: "John Doe"})
|
||||
|
||||
Enum.each(["mary", "a", ""], fn query ->
|
||||
assert [] == Search.search(query)
|
||||
end)
|
||||
end
|
||||
|
||||
test "works with URIs" do
|
||||
user = insert(:user)
|
||||
|
||||
results =
|
||||
Search.search("http://mastodon.example.org/users/admin", resolve: true, for_user: user)
|
||||
|
||||
result = results |> List.first()
|
||||
|
||||
user = User.get_cached_by_ap_id("http://mastodon.example.org/users/admin")
|
||||
|
||||
assert length(results) == 1
|
||||
|
||||
expected =
|
||||
result
|
||||
|> Map.put(:search_rank, nil)
|
||||
|> Map.put(:search_type, nil)
|
||||
|> Map.put(:last_digest_emailed_at, nil)
|
||||
|> Map.put(:multi_factor_authentication_settings, nil)
|
||||
|> Map.put(:notification_settings, nil)
|
||||
|
||||
assert_user_match(user, expected)
|
||||
end
|
||||
|
||||
test "excludes a blocked users from search result" do
|
||||
user = insert(:user, %{nickname: "Bill"})
|
||||
|
||||
[blocked_user | users] = Enum.map(0..3, &insert(:user, %{nickname: "john#{&1}"}))
|
||||
|
||||
blocked_user2 =
|
||||
insert(
|
||||
:user,
|
||||
%{nickname: "john awful", ap_id: "https://awful-and-rude-instance.com/user/bully"}
|
||||
)
|
||||
|
||||
User.block_domain(user, "awful-and-rude-instance.com")
|
||||
User.block(user, blocked_user)
|
||||
|
||||
account_ids = Search.search("john", for_user: refresh_record(user)) |> collect_ids
|
||||
|
||||
assert account_ids == collect_ids(users)
|
||||
refute Enum.member?(account_ids, blocked_user.id)
|
||||
refute Enum.member?(account_ids, blocked_user2.id)
|
||||
assert length(account_ids) == 3
|
||||
end
|
||||
|
||||
test "local user has the same search_rank as for users with the same nickname, but another domain" do
|
||||
user = insert(:user)
|
||||
insert(:user, nickname: "lain@mastodon.social")
|
||||
insert(:user, nickname: "lain")
|
||||
insert(:user, nickname: "lain@pleroma.social")
|
||||
|
||||
assert Search.search("lain@localhost", resolve: true, for_user: user)
|
||||
|> Enum.each(fn u -> u.search_rank == 0.5 end)
|
||||
end
|
||||
|
||||
test "localhost is the part of the domain" do
|
||||
user = insert(:user)
|
||||
insert(:user, nickname: "another@somedomain")
|
||||
insert(:user, nickname: "lain")
|
||||
insert(:user, nickname: "lain@examplelocalhost")
|
||||
|
||||
result = Search.search("lain@examplelocalhost", resolve: true, for_user: user)
|
||||
assert Enum.each(result, fn u -> u.search_rank == 0.5 end)
|
||||
assert length(result) == 1
|
||||
assert hd(result).nickname == "lain@examplelocalhost"
|
||||
end
|
||||
|
||||
test "local user search with users" do
|
||||
user = insert(:user)
|
||||
local_user = insert(:user, nickname: "lain")
|
||||
insert(:user, nickname: "another@localhost.com")
|
||||
insert(:user, nickname: "localhost@localhost.com")
|
||||
|
||||
[result] = Search.search("lain@localhost", resolve: true, for_user: user)
|
||||
assert Map.put(result, :search_rank, nil) |> Map.put(:search_type, nil) == local_user
|
||||
end
|
||||
|
||||
test "works with idna domains" do
|
||||
user = insert(:user, nickname: "lain@" <> to_string(:idna.encode("zetsubou.みんな")))
|
||||
|
||||
results = Search.search("lain@zetsubou.みんな", resolve: false, for_user: user)
|
||||
|
||||
result = List.first(results)
|
||||
|
||||
assert user == result |> Map.put(:search_rank, nil) |> Map.put(:search_type, nil)
|
||||
end
|
||||
|
||||
test "works with idna domains converted input" do
|
||||
user = insert(:user, nickname: "lain@" <> to_string(:idna.encode("zetsubou.みんな")))
|
||||
|
||||
results =
|
||||
Search.search("lain@" <> to_string(:idna.encode("zetsubou.みんな")),
|
||||
resolve: false,
|
||||
for_user: user
|
||||
)
|
||||
|
||||
result = List.first(results)
|
||||
|
||||
assert user == result |> Map.put(:search_rank, nil) |> Map.put(:search_type, nil)
|
||||
end
|
||||
|
||||
test "works with idna domains and query as link" do
|
||||
idnaenc = to_string(:idna.encode("zetsubou.みんな"))
|
||||
|
||||
user =
|
||||
insert(
|
||||
:user,
|
||||
nickname: "lain@" <> idnaenc,
|
||||
ap_id: "https://" <> idnaenc <> "/users/lain"
|
||||
)
|
||||
|
||||
results =
|
||||
Search.search("https://zetsubou.みんな/users/lain",
|
||||
resolve: false,
|
||||
for_user: user
|
||||
)
|
||||
|
||||
result = List.first(results)
|
||||
assert result
|
||||
|
||||
assert user == result |> Map.put(:search_rank, nil) |> Map.put(:search_type, nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,383 +0,0 @@
|
|||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Pleroma.UserSearchTest do
|
||||
alias Pleroma.User
|
||||
use Pleroma.DataCase
|
||||
|
||||
import Pleroma.Factory
|
||||
|
||||
setup_all do
|
||||
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "User.search" do
|
||||
setup do: clear_config([:instance, :limit_to_local_content])
|
||||
|
||||
test "returns a resolved user as the first result" do
|
||||
clear_config([:instance, :limit_to_local_content], false)
|
||||
user = insert(:user, %{nickname: "no_relation", ap_id: "https://lain.com/users/lain"})
|
||||
_user = insert(:user, %{nickname: "com_user"})
|
||||
|
||||
[first_user, _second_user] = User.search("https://lain.com/users/lain", resolve: true)
|
||||
|
||||
assert first_user.id == user.id
|
||||
end
|
||||
|
||||
test "returns a user with matching ap_id as the first result" do
|
||||
user = insert(:user, %{nickname: "no_relation", ap_id: "https://lain.com/users/lain"})
|
||||
_user = insert(:user, %{nickname: "com_user"})
|
||||
|
||||
[first_user, _second_user] = User.search("https://lain.com/users/lain")
|
||||
|
||||
assert first_user.id == user.id
|
||||
end
|
||||
|
||||
test "doesn't die if two users have the same uri" do
|
||||
insert(:user, %{uri: "https://gensokyo.2hu/@raymoo"})
|
||||
insert(:user, %{uri: "https://gensokyo.2hu/@raymoo"})
|
||||
assert [_first_user, _second_user] = User.search("https://gensokyo.2hu/@raymoo")
|
||||
end
|
||||
|
||||
test "returns a user with matching uri as the first result" do
|
||||
user =
|
||||
insert(:user, %{
|
||||
nickname: "no_relation",
|
||||
ap_id: "https://lain.com/users/lain",
|
||||
uri: "https://lain.com/@lain"
|
||||
})
|
||||
|
||||
_user = insert(:user, %{nickname: "com_user"})
|
||||
|
||||
[first_user, _second_user] = User.search("https://lain.com/@lain")
|
||||
|
||||
assert first_user.id == user.id
|
||||
end
|
||||
|
||||
test "excludes invisible users from results" do
|
||||
user = insert(:user, %{nickname: "john t1000"})
|
||||
insert(:user, %{invisible: true, nickname: "john t800"})
|
||||
|
||||
[found_user] = User.search("john")
|
||||
assert found_user.id == user.id
|
||||
end
|
||||
|
||||
test "excludes deactivated users from results" do
|
||||
user = insert(:user, %{nickname: "john t1000"})
|
||||
insert(:user, %{is_active: false, nickname: "john t800"})
|
||||
|
||||
[found_user] = User.search("john")
|
||||
assert found_user.id == user.id
|
||||
end
|
||||
|
||||
# Note: as in Mastodon, `is_discoverable` doesn't anyhow relate to user searchability
|
||||
test "includes non-discoverable users in results" do
|
||||
insert(:user, %{nickname: "john 3000", is_discoverable: false})
|
||||
insert(:user, %{nickname: "john 3001"})
|
||||
|
||||
users = User.search("john")
|
||||
assert Enum.count(users) == 2
|
||||
end
|
||||
|
||||
test "excludes service actors from results" do
|
||||
insert(:user, actor_type: "Application", nickname: "user1")
|
||||
service = insert(:user, actor_type: "Service", nickname: "user2")
|
||||
person = insert(:user, actor_type: "Person", nickname: "user3")
|
||||
|
||||
assert [found_user1, found_user2] = User.search("user")
|
||||
assert [found_user1.id, found_user2.id] -- [service.id, person.id] == []
|
||||
end
|
||||
|
||||
test "accepts limit parameter" do
|
||||
Enum.each(0..4, &insert(:user, %{nickname: "john#{&1}"}))
|
||||
assert length(User.search("john", limit: 3)) == 3
|
||||
assert length(User.search("john")) == 5
|
||||
end
|
||||
|
||||
test "accepts offset parameter" do
|
||||
Enum.each(0..4, &insert(:user, %{nickname: "john#{&1}"}))
|
||||
assert length(User.search("john", limit: 3)) == 3
|
||||
assert length(User.search("john", limit: 3, offset: 3)) == 2
|
||||
end
|
||||
|
||||
defp clear_virtual_fields(user) do
|
||||
Map.merge(user, %{search_rank: nil, search_type: nil})
|
||||
end
|
||||
|
||||
test "finds a user by full nickname or its leading fragment" do
|
||||
user = insert(:user, %{nickname: "john"})
|
||||
|
||||
Enum.each(["john", "jo", "j"], fn query ->
|
||||
assert user ==
|
||||
User.search(query)
|
||||
|> List.first()
|
||||
|> clear_virtual_fields()
|
||||
end)
|
||||
end
|
||||
|
||||
test "doesn't explode if GIN fuzzy limit is set" do
|
||||
clear_config([Pleroma.Search.DatabaseSearch, :gin_fuzzy_search_limit], 10_000)
|
||||
|
||||
user = insert(:user, %{nickname: "john"})
|
||||
|
||||
Enum.each(["john", "jo", "j"], fn query ->
|
||||
assert user ==
|
||||
User.search(query)
|
||||
|> List.first()
|
||||
|> clear_virtual_fields()
|
||||
end)
|
||||
end
|
||||
|
||||
test "finds a user by full name or leading fragment(s) of its words" do
|
||||
user = insert(:user, %{name: "John Doe"})
|
||||
|
||||
Enum.each(["John Doe", "JOHN", "doe", "j d", "j", "d"], fn query ->
|
||||
assert user ==
|
||||
User.search(query)
|
||||
|> List.first()
|
||||
|> clear_virtual_fields()
|
||||
end)
|
||||
end
|
||||
|
||||
test "matches by leading fragment of user domain" do
|
||||
user = insert(:user, %{nickname: "arandom@dude.com"})
|
||||
insert(:user, %{nickname: "iamthedude"})
|
||||
|
||||
assert [user.id] == User.search("dud") |> Enum.map(& &1.id)
|
||||
end
|
||||
|
||||
test "ranks full nickname match higher than full name match" do
|
||||
nicknamed_user = insert(:user, %{nickname: "hj@shigusegubu.club"})
|
||||
named_user = insert(:user, %{nickname: "xyz@sample.com", name: "HJ"})
|
||||
|
||||
results = User.search("hj")
|
||||
|
||||
assert [nicknamed_user.id, named_user.id] == Enum.map(results, & &1.id)
|
||||
assert Enum.at(results, 0).search_rank > Enum.at(results, 1).search_rank
|
||||
end
|
||||
|
||||
test "finds users, considering density of matched tokens" do
|
||||
u1 = insert(:user, %{name: "Bar Bar plus Word Word"})
|
||||
u2 = insert(:user, %{name: "Word Word Bar Bar Bar"})
|
||||
|
||||
assert [u2.id, u1.id] == Enum.map(User.search("bar word"), & &1.id)
|
||||
end
|
||||
|
||||
test "finds users, boosting ranks of friends and followers" do
|
||||
u1 = insert(:user)
|
||||
u2 = insert(:user, %{name: "Doe"})
|
||||
follower = insert(:user, %{name: "Doe"})
|
||||
friend = insert(:user, %{name: "Doe"})
|
||||
|
||||
{:ok, follower, u1} = User.follow(follower, u1)
|
||||
{:ok, u1, friend} = User.follow(u1, friend)
|
||||
|
||||
assert [friend.id, follower.id, u2.id] --
|
||||
Enum.map(User.search("doe", resolve: false, for_user: u1), & &1.id) == []
|
||||
end
|
||||
|
||||
test "finds followings of user by partial name" do
|
||||
lizz = insert(:user, %{name: "Lizz"})
|
||||
jimi = insert(:user, %{name: "Jimi"})
|
||||
following_lizz = insert(:user, %{name: "Jimi Hendrix"})
|
||||
following_jimi = insert(:user, %{name: "Lizz Wright"})
|
||||
follower_lizz = insert(:user, %{name: "Jimi"})
|
||||
|
||||
{:ok, lizz, following_lizz} = User.follow(lizz, following_lizz)
|
||||
{:ok, _jimi, _following_jimi} = User.follow(jimi, following_jimi)
|
||||
{:ok, _follower_lizz, _lizz} = User.follow(follower_lizz, lizz)
|
||||
|
||||
assert Enum.map(User.search("jimi", following: true, for_user: lizz), & &1.id) == [
|
||||
following_lizz.id
|
||||
]
|
||||
|
||||
assert User.search("lizz", following: true, for_user: lizz) == []
|
||||
end
|
||||
|
||||
test "find local and remote users for authenticated users" do
|
||||
u1 = insert(:user, %{name: "lain"})
|
||||
u2 = insert(:user, %{name: "ebn", nickname: "lain@mastodon.social", local: false})
|
||||
u3 = insert(:user, %{nickname: "lain@pleroma.soykaf.com", local: false})
|
||||
|
||||
results =
|
||||
"lain"
|
||||
|> User.search(for_user: u1)
|
||||
|> Enum.map(& &1.id)
|
||||
|> Enum.sort()
|
||||
|
||||
assert [u1.id, u2.id, u3.id] == results
|
||||
end
|
||||
|
||||
test "find only local users for unauthenticated users" do
|
||||
%{id: id} = insert(:user, %{name: "lain"})
|
||||
insert(:user, %{name: "ebn", nickname: "lain@mastodon.social", local: false})
|
||||
insert(:user, %{nickname: "lain@pleroma.soykaf.com", local: false})
|
||||
|
||||
assert [%{id: ^id}] = User.search("lain")
|
||||
end
|
||||
|
||||
test "find only local users for authenticated users when `limit_to_local_content` is `:all`" do
|
||||
clear_config([:instance, :limit_to_local_content], :all)
|
||||
|
||||
%{id: id} = insert(:user, %{name: "lain"})
|
||||
insert(:user, %{name: "ebn", nickname: "lain@mastodon.social", local: false})
|
||||
insert(:user, %{nickname: "lain@pleroma.soykaf.com", local: false})
|
||||
|
||||
assert [%{id: ^id}] = User.search("lain")
|
||||
end
|
||||
|
||||
test "find all users for unauthenticated users when `limit_to_local_content` is `false`" do
|
||||
clear_config([:instance, :limit_to_local_content], false)
|
||||
|
||||
u1 = insert(:user, %{name: "lain"})
|
||||
u2 = insert(:user, %{name: "ebn", nickname: "lain@mastodon.social", local: false})
|
||||
u3 = insert(:user, %{nickname: "lain@pleroma.soykaf.com", local: false})
|
||||
|
||||
results =
|
||||
"lain"
|
||||
|> User.search()
|
||||
|> Enum.map(& &1.id)
|
||||
|> Enum.sort()
|
||||
|
||||
assert [u1.id, u2.id, u3.id] == results
|
||||
end
|
||||
|
||||
test "does not yield false-positive matches" do
|
||||
insert(:user, %{name: "John Doe"})
|
||||
|
||||
Enum.each(["mary", "a", ""], fn query ->
|
||||
assert [] == User.search(query)
|
||||
end)
|
||||
end
|
||||
|
||||
test "works with URIs" do
|
||||
user = insert(:user)
|
||||
|
||||
results =
|
||||
User.search("http://mastodon.example.org/users/admin", resolve: true, for_user: user)
|
||||
|
||||
result = results |> List.first()
|
||||
|
||||
user = User.get_cached_by_ap_id("http://mastodon.example.org/users/admin")
|
||||
|
||||
assert length(results) == 1
|
||||
|
||||
expected =
|
||||
result
|
||||
|> Map.put(:search_rank, nil)
|
||||
|> Map.put(:search_type, nil)
|
||||
|> Map.put(:last_digest_emailed_at, nil)
|
||||
|> Map.put(:multi_factor_authentication_settings, nil)
|
||||
|> Map.put(:notification_settings, nil)
|
||||
|
||||
assert_user_match(user, expected)
|
||||
end
|
||||
|
||||
test "excludes a blocked users from search result" do
|
||||
user = insert(:user, %{nickname: "Bill"})
|
||||
|
||||
[blocked_user | users] = Enum.map(0..3, &insert(:user, %{nickname: "john#{&1}"}))
|
||||
|
||||
blocked_user2 =
|
||||
insert(
|
||||
:user,
|
||||
%{nickname: "john awful", ap_id: "https://awful-and-rude-instance.com/user/bully"}
|
||||
)
|
||||
|
||||
User.block_domain(user, "awful-and-rude-instance.com")
|
||||
User.block(user, blocked_user)
|
||||
|
||||
account_ids = User.search("john", for_user: refresh_record(user)) |> collect_ids
|
||||
|
||||
assert account_ids == collect_ids(users)
|
||||
refute Enum.member?(account_ids, blocked_user.id)
|
||||
refute Enum.member?(account_ids, blocked_user2.id)
|
||||
assert length(account_ids) == 3
|
||||
end
|
||||
|
||||
test "local user has the same search_rank as for users with the same nickname, but another domain" do
|
||||
user = insert(:user)
|
||||
insert(:user, nickname: "lain@mastodon.social")
|
||||
insert(:user, nickname: "lain")
|
||||
insert(:user, nickname: "lain@pleroma.social")
|
||||
|
||||
assert User.search("lain@localhost", resolve: true, for_user: user)
|
||||
|> Enum.each(fn u -> u.search_rank == 0.5 end)
|
||||
end
|
||||
|
||||
test "localhost is the part of the domain" do
|
||||
user = insert(:user)
|
||||
insert(:user, nickname: "another@somedomain")
|
||||
insert(:user, nickname: "lain")
|
||||
insert(:user, nickname: "lain@examplelocalhost")
|
||||
|
||||
result = User.search("lain@examplelocalhost", resolve: true, for_user: user)
|
||||
assert Enum.each(result, fn u -> u.search_rank == 0.5 end)
|
||||
assert length(result) == 2
|
||||
end
|
||||
|
||||
test "local user search with users" do
|
||||
user = insert(:user)
|
||||
local_user = insert(:user, nickname: "lain")
|
||||
insert(:user, nickname: "another@localhost.com")
|
||||
insert(:user, nickname: "localhost@localhost.com")
|
||||
|
||||
[result] = User.search("lain@localhost", resolve: true, for_user: user)
|
||||
assert Map.put(result, :search_rank, nil) |> Map.put(:search_type, nil) == local_user
|
||||
end
|
||||
|
||||
test "works with idna domains" do
|
||||
user = insert(:user, nickname: "lain@" <> to_string(:idna.encode("zetsubou.みんな")))
|
||||
|
||||
results = User.search("lain@zetsubou.みんな", resolve: false, for_user: user)
|
||||
|
||||
result = List.first(results)
|
||||
|
||||
assert user == result |> Map.put(:search_rank, nil) |> Map.put(:search_type, nil)
|
||||
end
|
||||
|
||||
test "works with idna domains converted input" do
|
||||
user = insert(:user, nickname: "lain@" <> to_string(:idna.encode("zetsubou.みんな")))
|
||||
|
||||
results =
|
||||
User.search("lain@zetsubou." <> to_string(:idna.encode("zetsubou.みんな")),
|
||||
resolve: false,
|
||||
for_user: user
|
||||
)
|
||||
|
||||
result = List.first(results)
|
||||
|
||||
assert user == result |> Map.put(:search_rank, nil) |> Map.put(:search_type, nil)
|
||||
end
|
||||
|
||||
test "works with idna domains and bad chars in domain" do
|
||||
user = insert(:user, nickname: "lain@" <> to_string(:idna.encode("zetsubou.みんな")))
|
||||
|
||||
results =
|
||||
User.search("lain@zetsubou!@#$%^&*()+,-/:;<=>?[]'_{}|~`.みんな",
|
||||
resolve: false,
|
||||
for_user: user
|
||||
)
|
||||
|
||||
result = List.first(results)
|
||||
|
||||
assert user == result |> Map.put(:search_rank, nil) |> Map.put(:search_type, nil)
|
||||
end
|
||||
|
||||
test "works with idna domains and query as link" do
|
||||
user = insert(:user, nickname: "lain@" <> to_string(:idna.encode("zetsubou.みんな")))
|
||||
|
||||
results =
|
||||
User.search("https://zetsubou.みんな/users/lain",
|
||||
resolve: false,
|
||||
for_user: user
|
||||
)
|
||||
|
||||
result = List.first(results)
|
||||
|
||||
assert user == result |> Map.put(:search_rank, nil) |> Map.put(:search_type, nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -21,8 +21,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
|
|||
describe ".search2" do
|
||||
test "it returns empty result if user or status search return undefined error", %{conn: conn} do
|
||||
with_mocks [
|
||||
{Pleroma.User, [], [search: fn _q, _o -> raise "Oops" end]},
|
||||
{Pleroma.Activity, [], [search: fn _u, _q, _o -> raise "Oops" end]}
|
||||
{Pleroma.User.Search, [], [search: fn _q, _o -> raise "Oops" end]},
|
||||
{Pleroma.Search.DatabaseSearch, [], [search: fn _u, _q, _o -> raise "Oops" end]}
|
||||
] do
|
||||
capture_log(fn ->
|
||||
results =
|
||||
|
|
@ -54,14 +54,14 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
|
|||
|
||||
results =
|
||||
conn
|
||||
|> get("/api/v2/search?#{URI.encode_query(%{q: "2hu #private"})}")
|
||||
|> get("/api/v2/search?#{URI.encode_query(%{q: "2hu"})}")
|
||||
|> json_response_and_validate_schema(200)
|
||||
|
||||
[account | _] = results["accounts"]
|
||||
assert account["id"] == to_string(user_three.id)
|
||||
|
||||
assert results["hashtags"] == [
|
||||
%{"name" => "private", "url" => "#{Endpoint.url()}/tag/private"}
|
||||
%{"name" => "2hu", "url" => "#{Endpoint.url()}/tag/2hu"}
|
||||
]
|
||||
|
||||
[status] = results["statuses"]
|
||||
|
|
@ -146,8 +146,18 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
|
|||
end
|
||||
|
||||
test "supports pagination of hashtags search results", %{conn: conn} do
|
||||
clear_config([:restrict_unauthenticated, :search], %{
|
||||
all: false,
|
||||
resolve: true,
|
||||
paginate: true
|
||||
})
|
||||
|
||||
user = insert(:user, local: true)
|
||||
|
||||
results =
|
||||
conn
|
||||
|> assign(:user, user)
|
||||
|> assign(:token, insert(:oauth_token, user: user, scopes: ["read"]))
|
||||
|> get(
|
||||
"/api/v2/search?#{URI.encode_query(%{q: "#some #text #with #hashtags", limit: 2, offset: 1})}"
|
||||
)
|
||||
|
|
@ -159,6 +169,26 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
|
|||
]
|
||||
end
|
||||
|
||||
test "does not paginate when unauthenticated and restricted", %{conn: conn} do
|
||||
clear_config([:restrict_unauthenticated, :search], %{
|
||||
all: false,
|
||||
resolve: true,
|
||||
paginate: true
|
||||
})
|
||||
|
||||
results =
|
||||
conn
|
||||
|> get(
|
||||
"/api/v2/search?#{URI.encode_query(%{q: "#some #text #with #hashtags", limit: 2, offset: 1})}"
|
||||
)
|
||||
|> json_response_and_validate_schema(200)
|
||||
|
||||
assert results["hashtags"] == [
|
||||
%{"name" => "some", "url" => "#{Endpoint.url()}/tag/some"},
|
||||
%{"name" => "text", "url" => "#{Endpoint.url()}/tag/text"}
|
||||
]
|
||||
end
|
||||
|
||||
test "excludes a blocked users from search results", %{conn: conn} do
|
||||
user = insert(:user)
|
||||
user_smith = insert(:user, %{nickname: "Agent", name: "I love 2hu"})
|
||||
|
|
@ -214,7 +244,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
|
|||
|
||||
results =
|
||||
conn
|
||||
|> get("/api/v1/accounts/search?q=shp@shitposter.club xxx")
|
||||
|> get("/api/v1/accounts/search?q=shp@shitposter.club%20")
|
||||
|> json_response_and_validate_schema(200)
|
||||
|
||||
assert length(results) == 1
|
||||
|
|
|
|||
|
|
@ -766,9 +766,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
|
|||
end
|
||||
|
||||
test "put the url advertised in the Activity in to the url attribute" do
|
||||
Pleroma.Config.put([:instance, :limit_to_local_content], false)
|
||||
id = "https://wedistribute.org/wp-json/pterotype/v1/object/85810"
|
||||
[activity] = Activity.search(nil, id)
|
||||
{:ok, object} = Pleroma.Object.Fetcher.fetch_object_from_id(id)
|
||||
activity = Pleroma.Activity.get_create_by_object_ap_id_with_object(object.data["id"])
|
||||
|
||||
status = StatusView.render("show.json", %{activity: activity})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue