Merge branch 'develop' into 'remove-twitter-api'
[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 import Pleroma.Web.ControllerHelper, only: [skip_relationships?: 1]
9
10 alias Pleroma.Activity
11 alias Pleroma.Plugs.OAuthScopesPlug
12 alias Pleroma.Plugs.RateLimiter
13 alias Pleroma.Repo
14 alias Pleroma.User
15 alias Pleroma.Web
16 alias Pleroma.Web.MastodonAPI.AccountView
17 alias Pleroma.Web.MastodonAPI.StatusView
18
19 require Logger
20
21 plug(Pleroma.Web.ApiSpec.CastAndValidate)
22
23 # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search)
24 plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated})
25
26 # Note: on private instances auth is required (EnsurePublicOrAuthenticatedPlug is not skipped)
27
28 plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search])
29
30 defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation
31
32 def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do
33 accounts = User.search(query, search_options(params, user))
34
35 conn
36 |> put_view(AccountView)
37 |> render("index.json", users: accounts, for: user, as: :user)
38 end
39
40 def search2(conn, params), do: do_search(:v2, conn, params)
41 def search(conn, params), do: do_search(:v1, conn, params)
42
43 defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do
44 options = search_options(params, user)
45 timeout = Keyword.get(Repo.config(), :timeout, 15_000)
46 default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []}
47
48 result =
49 default_values
50 |> Enum.map(fn {resource, default_value} ->
51 if params[:type] in [nil, resource] do
52 {resource, fn -> resource_search(version, resource, query, options) end}
53 else
54 {resource, fn -> default_value end}
55 end
56 end)
57 |> Task.async_stream(fn {resource, f} -> {resource, with_fallback(f)} end,
58 timeout: timeout,
59 on_timeout: :kill_task
60 )
61 |> Enum.reduce(default_values, fn
62 {:ok, {resource, result}}, acc ->
63 Map.put(acc, resource, result)
64
65 _error, acc ->
66 acc
67 end)
68
69 json(conn, result)
70 end
71
72 defp search_options(params, user) do
73 [
74 skip_relationships: skip_relationships?(params),
75 resolve: params[:resolve],
76 following: params[:following],
77 limit: params[:limit],
78 offset: params[:offset],
79 type: params[:type],
80 author: get_author(params),
81 for_user: user
82 ]
83 |> Enum.filter(&elem(&1, 1))
84 end
85
86 defp resource_search(_, "accounts", query, options) do
87 accounts = with_fallback(fn -> User.search(query, options) end)
88
89 AccountView.render("index.json",
90 users: accounts,
91 for: options[:for_user],
92 as: :user,
93 skip_relationships: false
94 )
95 end
96
97 defp resource_search(_, "statuses", query, options) do
98 statuses = with_fallback(fn -> Activity.search(options[:for_user], query, options) end)
99
100 StatusView.render("index.json",
101 activities: statuses,
102 for: options[:for_user],
103 as: :activity,
104 skip_relationships: options[:skip_relationships]
105 )
106 end
107
108 defp resource_search(:v2, "hashtags", query, _options) do
109 tags_path = Web.base_url() <> "/tag/"
110
111 query
112 |> prepare_tags()
113 |> Enum.map(fn tag ->
114 tag = String.trim_leading(tag, "#")
115 %{name: tag, url: tags_path <> tag}
116 end)
117 end
118
119 defp resource_search(:v1, "hashtags", query, _options) do
120 query
121 |> prepare_tags()
122 |> Enum.map(fn tag -> String.trim_leading(tag, "#") end)
123 end
124
125 defp prepare_tags(query) do
126 query
127 |> String.split()
128 |> Enum.uniq()
129 |> Enum.filter(fn tag -> String.starts_with?(tag, "#") end)
130 end
131
132 defp with_fallback(f, fallback \\ []) do
133 try do
134 f.()
135 rescue
136 error ->
137 Logger.error("#{__MODULE__} search error: #{inspect(error)}")
138 fallback
139 end
140 end
141
142 defp get_author(%{account_id: account_id}) when is_binary(account_id),
143 do: User.get_cached_by_id(account_id)
144
145 defp get_author(_params), do: nil
146 end