5e6e04734c47d9b38d96915daac87505887d040b
[anni] / lib / pleroma / web / mastodon_api / controllers / search_controller.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.MastodonAPI.SearchController do
6   use Pleroma.Web, :controller
7
8   alias Pleroma.Activity
9   alias Pleroma.Repo
10   alias Pleroma.User
11   alias Pleroma.Web.ControllerHelper
12   alias Pleroma.Web.Endpoint
13   alias Pleroma.Web.MastodonAPI.AccountView
14   alias Pleroma.Web.MastodonAPI.StatusView
15   alias Pleroma.Web.Plugs.OAuthScopesPlug
16   alias Pleroma.Web.Plugs.RateLimiter
17
18   require Logger
19
20   @search_limit 40
21
22   plug(Pleroma.Web.ApiSpec.CastAndValidate)
23
24   # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search)
25   plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated})
26
27   # Note: on private instances auth is required (EnsurePublicOrAuthenticatedPlug is not skipped)
28
29   plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search])
30
31   defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation
32
33   def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do
34     accounts = User.search(query, search_options(params, user))
35
36     conn
37     |> put_view(AccountView)
38     |> render("index.json",
39       users: accounts,
40       for: user,
41       as: :user
42     )
43   end
44
45   def search2(conn, params), do: do_search(:v2, conn, params)
46   def search(conn, params), do: do_search(:v1, conn, params)
47
48   defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do
49     query = String.trim(query)
50     options = search_options(params, user)
51     timeout = Keyword.get(Repo.config(), :timeout, 15_000)
52     default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []}
53
54     result =
55       default_values
56       |> Enum.map(fn {resource, default_value} ->
57         if params[:type] in [nil, resource] do
58           {resource, fn -> resource_search(version, resource, query, options) end}
59         else
60           {resource, fn -> default_value end}
61         end
62       end)
63       |> Task.async_stream(fn {resource, f} -> {resource, with_fallback(f)} end,
64         timeout: timeout,
65         on_timeout: :kill_task
66       )
67       |> Enum.reduce(default_values, fn
68         {:ok, {resource, result}}, acc ->
69           Map.put(acc, resource, result)
70
71         _error, acc ->
72           acc
73       end)
74
75     json(conn, result)
76   end
77
78   defp search_options(params, user) do
79     [
80       resolve: params[:resolve],
81       following: params[:following],
82       limit: min(params[:limit], @search_limit),
83       offset: params[:offset],
84       type: params[:type],
85       author: get_author(params),
86       embed_relationships: ControllerHelper.embed_relationships?(params),
87       for_user: user
88     ]
89     |> Enum.filter(&elem(&1, 1))
90   end
91
92   defp resource_search(_, "accounts", query, options) do
93     accounts = with_fallback(fn -> User.search(query, options) end)
94
95     AccountView.render("index.json",
96       users: accounts,
97       for: options[:for_user],
98       embed_relationships: options[:embed_relationships]
99     )
100   end
101
102   defp resource_search(_, "statuses", query, options) do
103     statuses = with_fallback(fn -> Activity.search(options[:for_user], query, options) end)
104
105     StatusView.render("index.json",
106       activities: statuses,
107       for: options[:for_user],
108       as: :activity
109     )
110   end
111
112   defp resource_search(:v2, "hashtags", query, options) do
113     tags_path = Endpoint.url() <> "/tag/"
114
115     query
116     |> prepare_tags(options)
117     |> Enum.map(fn tag ->
118       %{name: tag, url: tags_path <> tag}
119     end)
120   end
121
122   defp resource_search(:v1, "hashtags", query, options) do
123     prepare_tags(query, options)
124   end
125
126   defp prepare_tags(query, options) do
127     tags =
128       query
129       |> preprocess_uri_query()
130       |> String.split(~r/[^#\w]+/u, trim: true)
131       |> Enum.uniq_by(&String.downcase/1)
132
133     explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end)
134
135     tags =
136       if Enum.any?(explicit_tags) do
137         explicit_tags
138       else
139         tags
140       end
141
142     tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end)
143
144     tags =
145       if Enum.empty?(explicit_tags) && !options[:skip_joined_tag] do
146         add_joined_tag(tags)
147       else
148         tags
149       end
150
151     Pleroma.Pagination.paginate(tags, options)
152   end
153
154   defp add_joined_tag(tags) do
155     tags
156     |> Kernel.++([joined_tag(tags)])
157     |> Enum.uniq_by(&String.downcase/1)
158   end
159
160   # If `query` is a URI, returns last component of its path, otherwise returns `query`
161   defp preprocess_uri_query(query) do
162     if query =~ ~r/https?:\/\// do
163       query
164       |> String.trim_trailing("/")
165       |> URI.parse()
166       |> Map.get(:path)
167       |> String.split("/")
168       |> Enum.at(-1)
169     else
170       query
171     end
172   end
173
174   defp joined_tag(tags) do
175     tags
176     |> Enum.map(fn tag -> String.capitalize(tag) end)
177     |> Enum.join()
178   end
179
180   defp with_fallback(f, fallback \\ []) do
181     try do
182       f.()
183     rescue
184       error ->
185         Logger.error("#{__MODULE__} search error: #{inspect(error)}")
186         fallback
187     end
188   end
189
190   defp get_author(%{account_id: account_id}) when is_binary(account_id),
191     do: User.get_cached_by_id(account_id)
192
193   defp get_author(_params), do: nil
194 end