Merge branch 'object-normalize-refactor' into 'develop'
[akkoma] / lib / pleroma / web / mastodon_api / controllers / search_controller.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 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
12 alias Pleroma.Web.ControllerHelper
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 timeout = Keyword.get(Repo.config(), :timeout, 15_000)
50 default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []}
51
52 result =
53 default_values
54 |> Enum.map(fn {resource, default_value} ->
55 if params[:type] in [nil, resource] do
56 {resource, fn -> resource_search(version, resource, query, options) end}
57 else
58 {resource, fn -> default_value end}
59 end
60 end)
61 |> Task.async_stream(fn {resource, f} -> {resource, with_fallback(f)} end,
62 timeout: timeout,
63 on_timeout: :kill_task
64 )
65 |> Enum.reduce(default_values, fn
66 {:ok, {resource, result}}, acc ->
67 Map.put(acc, resource, result)
68
69 _error, acc ->
70 acc
71 end)
72
73 json(conn, result)
74 end
75
76 defp search_options(params, user) do
77 [
78 resolve: params[:resolve],
79 following: params[:following],
80 limit: params[:limit],
81 offset: params[:offset],
82 type: params[:type],
83 author: get_author(params),
84 embed_relationships: ControllerHelper.embed_relationships?(params),
85 for_user: user
86 ]
87 |> Enum.filter(&elem(&1, 1))
88 end
89
90 defp resource_search(_, "accounts", query, options) do
91 accounts = with_fallback(fn -> User.search(query, options) end)
92
93 AccountView.render("index.json",
94 users: accounts,
95 for: options[:for_user],
96 embed_relationships: options[:embed_relationships]
97 )
98 end
99
100 defp resource_search(_, "statuses", query, options) do
101 statuses = with_fallback(fn -> Activity.search(options[:for_user], query, options) end)
102
103 StatusView.render("index.json",
104 activities: statuses,
105 for: options[:for_user],
106 as: :activity
107 )
108 end
109
110 defp resource_search(:v2, "hashtags", query, options) do
111 tags_path = Web.base_url() <> "/tag/"
112
113 query
114 |> prepare_tags(options)
115 |> Enum.map(fn tag ->
116 %{name: tag, url: tags_path <> tag}
117 end)
118 end
119
120 defp resource_search(:v1, "hashtags", query, options) do
121 prepare_tags(query, options)
122 end
123
124 defp prepare_tags(query, options) do
125 tags =
126 query
127 |> preprocess_uri_query()
128 |> String.split(~r/[^#\w]+/u, trim: true)
129 |> Enum.uniq_by(&String.downcase/1)
130
131 explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end)
132
133 tags =
134 if Enum.any?(explicit_tags) do
135 explicit_tags
136 else
137 tags
138 end
139
140 tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end)
141
142 tags =
143 if Enum.empty?(explicit_tags) && !options[:skip_joined_tag] do
144 add_joined_tag(tags)
145 else
146 tags
147 end
148
149 Pleroma.Pagination.paginate(tags, options)
150 end
151
152 defp add_joined_tag(tags) do
153 tags
154 |> Kernel.++([joined_tag(tags)])
155 |> Enum.uniq_by(&String.downcase/1)
156 end
157
158 # If `query` is a URI, returns last component of its path, otherwise returns `query`
159 defp preprocess_uri_query(query) do
160 if query =~ ~r/https?:\/\// do
161 query
162 |> String.trim_trailing("/")
163 |> URI.parse()
164 |> Map.get(:path)
165 |> String.split("/")
166 |> Enum.at(-1)
167 else
168 query
169 end
170 end
171
172 defp joined_tag(tags) do
173 tags
174 |> Enum.map(fn tag -> String.capitalize(tag) end)
175 |> Enum.join()
176 end
177
178 defp with_fallback(f, fallback \\ []) do
179 try do
180 f.()
181 rescue
182 error ->
183 Logger.error("#{__MODULE__} search error: #{inspect(error)}")
184 fallback
185 end
186 end
187
188 defp get_author(%{account_id: account_id}) when is_binary(account_id),
189 do: User.get_cached_by_id(account_id)
190
191 defp get_author(_params), do: nil
192 end