search_controller.ex 4.96 KB
Newer Older
1
# Pleroma: A lightweight social networking server
2
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
3
4
5
6
# SPDX-License-Identifier: AGPL-3.0-only

defmodule Pleroma.Web.MastodonAPI.SearchController do
  use Pleroma.Web, :controller
7

8
  alias Pleroma.Activity
9
  alias Pleroma.Plugs.OAuthScopesPlug
10
  alias Pleroma.Plugs.RateLimiter
11
  alias Pleroma.Repo
12
13
  alias Pleroma.User
  alias Pleroma.Web
14
  alias Pleroma.Web.ControllerHelper
15
16
17
18
  alias Pleroma.Web.MastodonAPI.AccountView
  alias Pleroma.Web.MastodonAPI.StatusView

  require Logger
19

20
21
  plug(Pleroma.Web.ApiSpec.CastAndValidate)

22
23
24
  # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search)
  plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated})

25
26
  # Note: on private instances auth is required (EnsurePublicOrAuthenticatedPlug is not skipped)

Steven Fuchs's avatar
Steven Fuchs committed
27
  plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search])
28

29
30
31
  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation

  def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do
32
33
    accounts = User.search(query, search_options(params, user))

Steven Fuchs's avatar
Steven Fuchs committed
34
35
    conn
    |> put_view(AccountView)
36
37
38
    |> render("index.json",
      users: accounts,
      for: user,
39
      as: :user
40
    )
41
42
  end

43
44
45
  def search2(conn, params), do: do_search(:v2, conn, params)
  def search(conn, params), do: do_search(:v1, conn, params)

46
  defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do
47
48
49
50
51
52
53
    options = search_options(params, user)
    timeout = Keyword.get(Repo.config(), :timeout, 15_000)
    default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []}

    result =
      default_values
      |> Enum.map(fn {resource, default_value} ->
54
        if params[:type] in [nil, resource] do
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
          {resource, fn -> resource_search(version, resource, query, options) end}
        else
          {resource, fn -> default_value end}
        end
      end)
      |> Task.async_stream(fn {resource, f} -> {resource, with_fallback(f)} end,
        timeout: timeout,
        on_timeout: :kill_task
      )
      |> Enum.reduce(default_values, fn
        {:ok, {resource, result}}, acc ->
          Map.put(acc, resource, result)

        _error, acc ->
          acc
      end)

    json(conn, result)
73
74
  end

75
76
  defp search_options(params, user) do
    [
77
78
79
80
81
      resolve: params[:resolve],
      following: params[:following],
      limit: params[:limit],
      offset: params[:offset],
      type: params[:type],
82
      author: get_author(params),
83
      embed_relationships: ControllerHelper.embed_relationships?(params),
84
85
      for_user: user
    ]
86
87
88
89
90
    |> Enum.filter(&elem(&1, 1))
  end

  defp resource_search(_, "accounts", query, options) do
    accounts = with_fallback(fn -> User.search(query, options) end)
91
92
93
94

    AccountView.render("index.json",
      users: accounts,
      for: options[:for_user],
95
96
      as: :user,
      embed_relationships: options[:embed_relationships]
97
    )
98
99
100
101
  end

  defp resource_search(_, "statuses", query, options) do
    statuses = with_fallback(fn -> Activity.search(options[:for_user], query, options) end)
102
103
104
105

    StatusView.render("index.json",
      activities: statuses,
      for: options[:for_user],
106
      as: :activity
107
    )
108
109
110
111
112
113
114
115
116
117
118
119
120
  end

  defp resource_search(:v2, "hashtags", query, _options) do
    tags_path = Web.base_url() <> "/tag/"

    query
    |> prepare_tags()
    |> Enum.map(fn tag ->
      %{name: tag, url: tags_path <> tag}
    end)
  end

  defp resource_search(:v1, "hashtags", query, _options) do
121
    prepare_tags(query)
122
123
  end

124
125
126
  defp prepare_tags(query, add_joined_tag \\ true) do
    tags =
      query
127
      |> preprocess_uri_query()
128
      |> String.split(~r/[^#\w]+/u, trim: true)
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
      |> Enum.uniq_by(&String.downcase/1)

    explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end)

    tags =
      if Enum.any?(explicit_tags) do
        explicit_tags
      else
        tags
      end

    tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end)

    if Enum.empty?(explicit_tags) && add_joined_tag do
      tags
      |> Kernel.++([joined_tag(tags)])
      |> Enum.uniq_by(&String.downcase/1)
    else
      tags
    end
  end

151
152
153
154
  # If `query` is a URI, returns last component of its path, otherwise returns `query`
  defp preprocess_uri_query(query) do
    if query =~ ~r/https?:\/\// do
      query
155
      |> String.trim_trailing("/")
156
157
158
159
160
161
162
163
164
      |> URI.parse()
      |> Map.get(:path)
      |> String.split("/")
      |> Enum.at(-1)
    else
      query
    end
  end

165
166
167
168
  defp joined_tag(tags) do
    tags
    |> Enum.map(fn tag -> String.capitalize(tag) end)
    |> Enum.join()
169
  end
170

171
  defp with_fallback(f, fallback \\ []) do
172
173
174
175
176
177
178
179
    try do
      f.()
    rescue
      error ->
        Logger.error("#{__MODULE__} search error: #{inspect(error)}")
        fallback
    end
  end
180

181
  defp get_author(%{account_id: account_id}) when is_binary(account_id),
182
183
184
    do: User.get_cached_by_id(account_id)

  defp get_author(_params), do: nil
185
end