Merge pull request 'Tweak search' (#1113) from Oneric/akkoma:search-overhaul into develop
Some checks failed
ci/woodpecker/push/publish/2 Pipeline is pending
ci/woodpecker/push/docs Pipeline was successful
ci/woodpecker/push/publish/4 Pipeline failed
ci/woodpecker/push/publish/1 Pipeline failed

Reviewed-on: #1113
This commit is contained in:
Oneric 2026-05-22 20:25:09 +00:00
commit fb392a8562
33 changed files with 1094 additions and 769 deletions

View file

@ -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)

View file

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

View file

@ -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)."
}
]
}
]
},

View file

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

View file

@ -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
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 =

View file

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

View file

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

View file

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

View file

@ -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, theres clearly intent to see the account anyway.
# Similarly, if theres 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

View file

@ -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)

View file

@ -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,

View file

@ -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
)

View file

@ -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
)

View file

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

View file

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

View file

@ -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 couldnt 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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
defmodule Pleroma.Repo.Migrations.UpdateObjectFTSIndex do
use Ecto.Migration
def change(), do: :ok
end

View file

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

View file

@ -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)

View file

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

View file

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

View file

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

View file

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

View file

@ -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})