Merge branch 'patch-1' 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.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 options = search_options(params, user)
48 timeout = Keyword.get(Repo.config(), :timeout, 15_000)
49 default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []}
50
51 result =
52 default_values
53 |> Enum.map(fn {resource, default_value} ->
54 if params[:type] in [nil, resource] do
55 {resource, fn -> resource_search(version, resource, query, options) end}
56 else
57 {resource, fn -> default_value end}
58 end
59 end)
60 |> Task.async_stream(fn {resource, f} -> {resource, with_fallback(f)} end,
61 timeout: timeout,
62 on_timeout: :kill_task
63 )
64 |> Enum.reduce(default_values, fn
65 {:ok, {resource, result}}, acc ->
66 Map.put(acc, resource, result)
67
68 _error, acc ->
69 acc
70 end)
71
72 json(conn, result)
73 end
74
75 defp search_options(params, user) do
76 [
77 resolve: params[:resolve],
78 following: params[:following],
79 limit: params[:limit],
80 offset: params[:offset],
81 type: params[:type],
82 author: get_author(params),
83 embed_relationships: ControllerHelper.embed_relationships?(params),
84 for_user: user
85 ]
86 |> Enum.filter(&elem(&1, 1))
87 end
88
89 defp resource_search(_, "accounts", query, options) do
90 accounts = with_fallback(fn -> User.search(query, options) end)
91
92 AccountView.render("index.json",
93 users: accounts,
94 for: options[:for_user],
95 as: :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()
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)
122 end
123
124 defp prepare_tags(query, add_joined_tag \\ true) 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 if Enum.empty?(explicit_tags) && add_joined_tag do
143 tags
144 |> Kernel.++([joined_tag(tags)])
145 |> Enum.uniq_by(&String.downcase/1)
146 else
147 tags
148 end
149 end
150
151 # If `query` is a URI, returns last component of its path, otherwise returns `query`
152 defp preprocess_uri_query(query) do
153 if query =~ ~r/https?:\/\// do
154 query
155 |> String.trim_trailing("/")
156 |> URI.parse()
157 |> Map.get(:path)
158 |> String.split("/")
159 |> Enum.at(-1)
160 else
161 query
162 end
163 end
164
165 defp joined_tag(tags) do
166 tags
167 |> Enum.map(fn tag -> String.capitalize(tag) end)
168 |> Enum.join()
169 end
170
171 defp with_fallback(f, fallback \\ []) do
172 try do
173 f.()
174 rescue
175 error ->
176 Logger.error("#{__MODULE__} search error: #{inspect(error)}")
177 fallback
178 end
179 end
180
181 defp get_author(%{account_id: account_id}) when is_binary(account_id),
182 do: User.get_cached_by_id(account_id)
183
184 defp get_author(_params), do: nil
185 end