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