Check if mogrify available before calling it
[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.Plugs.OAuthScopesPlug
10 alias Pleroma.Plugs.RateLimiter
11 alias Pleroma.Repo
12 alias Pleroma.User
13 alias Pleroma.Web
14 alias Pleroma.Web.ControllerHelper
15 alias Pleroma.Web.MastodonAPI.AccountView
16 alias Pleroma.Web.MastodonAPI.StatusView
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 as: :user,
97 embed_relationships: options[:embed_relationships]
98 )
99 end
100
101 defp resource_search(_, "statuses", query, options) do
102 statuses = with_fallback(fn -> Activity.search(options[:for_user], query, options) end)
103
104 StatusView.render("index.json",
105 activities: statuses,
106 for: options[:for_user],
107 as: :activity
108 )
109 end
110
111 defp resource_search(:v2, "hashtags", query, options) do
112 tags_path = Web.base_url() <> "/tag/"
113
114 query
115 |> prepare_tags(options)
116 |> Enum.map(fn tag ->
117 %{name: tag, url: tags_path <> tag}
118 end)
119 end
120
121 defp resource_search(:v1, "hashtags", query, options) do
122 prepare_tags(query, options)
123 end
124
125 defp prepare_tags(query, options) do
126 tags =
127 query
128 |> preprocess_uri_query()
129 |> String.split(~r/[^#\w]+/u, trim: true)
130 |> Enum.uniq_by(&String.downcase/1)
131
132 explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end)
133
134 tags =
135 if Enum.any?(explicit_tags) do
136 explicit_tags
137 else
138 tags
139 end
140
141 tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end)
142
143 tags =
144 if Enum.empty?(explicit_tags) && !options[:skip_joined_tag] do
145 add_joined_tag(tags)
146 else
147 tags
148 end
149
150 Pleroma.Pagination.paginate(tags, options)
151 end
152
153 defp add_joined_tag(tags) do
154 tags
155 |> Kernel.++([joined_tag(tags)])
156 |> Enum.uniq_by(&String.downcase/1)
157 end
158
159 # If `query` is a URI, returns last component of its path, otherwise returns `query`
160 defp preprocess_uri_query(query) do
161 if query =~ ~r/https?:\/\// do
162 query
163 |> String.trim_trailing("/")
164 |> URI.parse()
165 |> Map.get(:path)
166 |> String.split("/")
167 |> Enum.at(-1)
168 else
169 query
170 end
171 end
172
173 defp joined_tag(tags) do
174 tags
175 |> Enum.map(fn tag -> String.capitalize(tag) end)
176 |> Enum.join()
177 end
178
179 defp with_fallback(f, fallback \\ []) do
180 try do
181 f.()
182 rescue
183 error ->
184 Logger.error("#{__MODULE__} search error: #{inspect(error)}")
185 fallback
186 end
187 end
188
189 defp get_author(%{account_id: account_id}) when is_binary(account_id),
190 do: User.get_cached_by_id(account_id)
191
192 defp get_author(_params), do: nil
193 end