X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Fpleroma%2Fuser%2Fsearch.ex;h=ddce51775bd2dd74cb454f49ddbea6eab8f14111;hb=a079ec3a3cdfd42d2cbd51c7698c2c87828e5778;hp=e74143cd0d115b52b636b908c453cb61a57ebac0;hpb=489bd30fb93543acfe70ef70db3830c62e5980bc;p=akkoma diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index e74143cd0..ddce51775 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -1,83 +1,231 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2021 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.User.Search do - alias Pleroma.Repo + alias Pleroma.EctoType.ActivityPub.ObjectValidators.Uri, as: UriType + alias Pleroma.Pagination alias Pleroma.User + import Ecto.Query - def search(query, opts \\ []) do + @limit 20 + + def search(query_string, opts \\ []) do resolve = Keyword.get(opts, :resolve, false) + 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) - # Strip the beginning @ off if there is a query - query = String.trim_leading(query, "@") + query_string = format_query(query_string) - maybe_resolve(resolve, for_user, query) + # If this returns anything, it should bounce to the top + maybe_resolved = maybe_resolve(resolve, for_user, query_string) - {:ok, results} = - Repo.transaction(fn -> - Ecto.Adapters.SQL.query(Repo, "select set_limit(0.25)", []) + top_user_ids = + [] + |> maybe_add_resolved(maybe_resolved) + |> maybe_add_ap_id_match(query_string) + |> maybe_add_uri_match(query_string) - query - |> search_query(for_user) - |> Repo.all() - end) + results = + query_string + |> search_query(for_user, following, top_user_ids) + |> Pagination.fetch_paginated(%{"offset" => offset, "limit" => result_limit}, :offset) results end - defp maybe_resolve(true, %User{}, query) do - User.get_or_fetch(query) + defp maybe_add_resolved(list, {:ok, %User{} = user}) do + [user.id | list] end - defp maybe_resolve(true, _, query) do - unless restrict_local?(), do: User.get_or_fetch(query) + 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] + else + list + end end - defp maybe_resolve(_, _, _), do: :noop + 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 + end - defp search_query(query, for_user) do - query - |> union_query() - |> distinct_query() - |> boost_search_rank_query(for_user) + def sanitise_domain(domain) do + domain + |> String.replace(~r/[!-\,|@|?|<|>|[-`|{-~|\/|:|\s]+/, "") + end + + 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) + |> 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) - |> limit(20) |> maybe_restrict_local(for_user) + |> filter_deactivated_users() end - defp restrict_local? do - Pleroma.Config.get([:instance, :limit_unauthenticated_to_local_content], true) + defp select_top_users(query, top_user_ids) do + from(u in query, + or_where: u.id in ^top_user_ids + ) end - defp union_query(query) do - fts_subquery = fts_search_subquery(query) - trigram_subquery = trigram_search_subquery(query) + defp fts_search(query, query_string) do + query_string = to_tsquery(query_string) - from(s in trigram_subquery, union_all: ^fts_subquery) + from( + u in query, + where: + fragment( + # The fragment must _exactly_ match `users_fts_index`, otherwise the index won't work + """ + ( + setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') || + setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B') + ) @@ to_tsquery('simple', ?) + """, + u.nickname, + u.name, + ^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 - defp distinct_query(q) do - from(s in subquery(q), order_by: s.search_type, distinct: s.id) + # Considers nickname match, localized nickname match, name match; preferences nickname match + defp trigram_rank(query, query_string) do + from( + u in query, + select_merge: %{ + search_rank: + fragment( + """ + similarity(?, ?) + + similarity(?, regexp_replace(?, '@.+', '')) + + similarity(?, trim(coalesce(?, ''))) + """, + ^query_string, + u.nickname, + ^query_string, + u.nickname, + ^query_string, + u.name + ) + } + ) end - # unauthenticated users can only search local activities - defp maybe_restrict_local(q, %User{}), do: q + defp base_query(%User{} = user, true), do: User.get_friends_query(user) + defp base_query(_user, _following), do: User - defp maybe_restrict_local(q, _) do - if restrict_local?() do - where(q, [u], u.local == true) - else - q + defp filter_invisible_users(query) do + from(q in query, where: q.invisible == false) + end + + defp filter_internal_users(query) do + from(q in query, where: q.actor_type != "Application") + end + + defp filter_deactivated_users(query) do + from(q in query, where: q.is_active == true) + end + + defp filter_blocked_user(query, %User{} = blocker) do + query + |> join(:left, [u], b in Pleroma.UserRelationship, + as: :blocks, + on: b.relationship_type == ^:block and b.source_id == ^blocker.id and u.id == b.target_id + ) + |> where([blocks: b], is_nil(b.target_id)) + end + + defp filter_blocked_user(query, _), do: query + + defp filter_blocked_domains(query, %User{domain_blocks: domain_blocks}) + when length(domain_blocks) > 0 do + domains = Enum.join(domain_blocks, ",") + + from( + q in query, + where: fragment("substring(ap_id from '.*://([^/]*)') 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 boost_search_rank_query(query, nil), do: query + defp limit, do: Pleroma.Config.get([:instance, :limit_to_local_content], :unauthenticated) - defp boost_search_rank_query(query, for_user) do + 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) @@ -86,9 +234,10 @@ defmodule Pleroma.User.Search do search_rank: fragment( """ - CASE WHEN (?) THEN (?) * 1.3 - WHEN (?) THEN (?) * 1.2 + 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, @@ -97,70 +246,26 @@ defmodule Pleroma.User.Search do u.search_rank, u.id in ^followers_ids, u.search_rank, + u.id in ^top_user_ids, u.search_rank ) } ) end - defp fts_search_subquery(term, query \\ User) do - processed_query = - term - |> String.replace(~r/\W+/, " ") - |> String.trim() - |> String.split() - |> Enum.map(&(&1 <> ":*")) - |> Enum.join(" | ") - - from( - u in query, + defp boost_search_rank(query, _for_user, top_user_ids) do + from(u in subquery(query), select_merge: %{ - search_type: ^0, search_rank: fragment( """ - ts_rank_cd( - setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') || - setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B'), - to_tsquery('simple', ?), - 32 - ) + CASE WHEN (?) THEN 9001 + ELSE (?) END """, - u.nickname, - u.name, - ^processed_query - ) - }, - where: - fragment( - """ - (setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') || - setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B')) @@ to_tsquery('simple', ?) - """, - u.nickname, - u.name, - ^processed_query - ) - ) - |> User.restrict_deactivated() - end - - defp trigram_search_subquery(term) do - from( - u in User, - select_merge: %{ - # ^1 gives 'Postgrex expected a binary, got 1' for some weird reason - search_type: fragment("?", 1), - search_rank: - fragment( - "similarity(?, trim(? || ' ' || coalesce(?, '')))", - ^term, - u.nickname, - u.name + u.id in ^top_user_ids, + u.search_rank ) - }, - where: fragment("trim(? || ' ' || coalesce(?, '')) % ?", u.nickname, u.name, ^term) + } ) - |> User.restrict_deactivated() end end