Add spec for AccountController.unfollow
[akkoma] / lib / pleroma / web / mastodon_api / controllers / account_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.AccountController do
6 use Pleroma.Web, :controller
7
8 import Pleroma.Web.ControllerHelper,
9 only: [
10 add_link_headers: 2,
11 truthy_param?: 1,
12 assign_account_by_id: 2,
13 json_response: 3,
14 skip_relationships?: 1
15 ]
16
17 alias Pleroma.Plugs.OAuthScopesPlug
18 alias Pleroma.Plugs.RateLimiter
19 alias Pleroma.User
20 alias Pleroma.Web.ActivityPub.ActivityPub
21 alias Pleroma.Web.CommonAPI
22 alias Pleroma.Web.MastodonAPI.ListView
23 alias Pleroma.Web.MastodonAPI.MastodonAPI
24 alias Pleroma.Web.MastodonAPI.StatusView
25 alias Pleroma.Web.OAuth.Token
26 alias Pleroma.Web.TwitterAPI.TwitterAPI
27
28 plug(
29 OAuthScopesPlug,
30 %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]}
31 when action == :show
32 )
33
34 plug(
35 OAuthScopesPlug,
36 %{scopes: ["read:accounts"]}
37 when action in [:endorsements, :verify_credentials, :followers, :following]
38 )
39
40 plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :update_credentials)
41
42 plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :lists)
43
44 plug(
45 OAuthScopesPlug,
46 %{scopes: ["follow", "read:blocks"]} when action == :blocks
47 )
48
49 plug(
50 OAuthScopesPlug,
51 %{scopes: ["follow", "write:blocks"]} when action in [:block, :unblock]
52 )
53
54 plug(OAuthScopesPlug, %{scopes: ["read:follows"]} when action == :relationships)
55
56 # Note: :follows (POST /api/v1/follows) is the same as :follow, consider removing :follows
57 plug(
58 OAuthScopesPlug,
59 %{scopes: ["follow", "write:follows"]} when action in [:follows, :follow, :unfollow]
60 )
61
62 plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes)
63
64 plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute])
65
66 plug(
67 Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
68 when action not in [:create, :show, :statuses]
69 )
70
71 @relationship_actions [:follow, :unfollow]
72 @needs_account ~W(followers following lists follow unfollow mute unmute block unblock)a
73
74 plug(
75 RateLimiter,
76 [name: :relation_id_action, params: ["id", "uri"]] when action in @relationship_actions
77 )
78
79 plug(RateLimiter, [name: :relations_actions] when action in @relationship_actions)
80 plug(RateLimiter, [name: :app_account_creation] when action == :create)
81 plug(:assign_account_by_id when action in @needs_account)
82
83 plug(
84 OpenApiSpex.Plug.CastAndValidate,
85 [render_error: Pleroma.Web.ApiSpec.RenderError]
86 when action in [
87 :create,
88 :verify_credentials,
89 :update_credentials,
90 :relationships,
91 :show,
92 :statuses,
93 :followers,
94 :following,
95 :lists,
96 :follow,
97 :unfollow
98 ]
99 )
100
101 action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
102
103 defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AccountOperation
104
105 @doc "POST /api/v1/accounts"
106 def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do
107 params =
108 params
109 |> Map.take([
110 :email,
111 :bio,
112 :captcha_solution,
113 :captcha_token,
114 :captcha_answer_data,
115 :token,
116 :password,
117 :fullname
118 ])
119 |> Map.put(:nickname, params.username)
120 |> Map.put(:fullname, params.fullname || params.username)
121 |> Map.put(:bio, params.bio || "")
122 |> Map.put(:confirm, params.password)
123
124 with :ok <- validate_email_param(params),
125 {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true),
126 {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do
127 json(conn, %{
128 token_type: "Bearer",
129 access_token: token.token,
130 scope: app.scopes,
131 created_at: Token.Utils.format_created_at(token)
132 })
133 else
134 {:error, errors} -> json_response(conn, :bad_request, errors)
135 end
136 end
137
138 def create(%{assigns: %{app: _app}} = conn, _) do
139 render_error(conn, :bad_request, "Missing parameters")
140 end
141
142 def create(conn, _) do
143 render_error(conn, :forbidden, "Invalid credentials")
144 end
145
146 defp validate_email_param(%{:email => email}) when not is_nil(email), do: :ok
147
148 defp validate_email_param(_) do
149 case Pleroma.Config.get([:instance, :account_activation_required]) do
150 true -> {:error, %{"error" => "Missing parameters"}}
151 _ -> :ok
152 end
153 end
154
155 @doc "GET /api/v1/accounts/verify_credentials"
156 def verify_credentials(%{assigns: %{user: user}} = conn, _) do
157 chat_token = Phoenix.Token.sign(conn, "user socket", user.id)
158
159 render(conn, "show.json",
160 user: user,
161 for: user,
162 with_pleroma_settings: true,
163 with_chat_token: chat_token
164 )
165 end
166
167 @doc "PATCH /api/v1/accounts/update_credentials"
168 def update_credentials(%{assigns: %{user: original_user}, body_params: params} = conn, _params) do
169 user = original_user
170
171 params =
172 params
173 |> Map.from_struct()
174 |> Enum.filter(fn {_, value} -> not is_nil(value) end)
175 |> Enum.into(%{})
176
177 user_params =
178 [
179 :no_rich_text,
180 :locked,
181 :hide_followers_count,
182 :hide_follows_count,
183 :hide_followers,
184 :hide_follows,
185 :hide_favorites,
186 :show_role,
187 :skip_thread_containment,
188 :allow_following_move,
189 :discoverable
190 ]
191 |> Enum.reduce(%{}, fn key, acc ->
192 add_if_present(acc, params, key, key, &{:ok, truthy_param?(&1)})
193 end)
194 |> add_if_present(params, :display_name, :name)
195 |> add_if_present(params, :note, :bio)
196 |> add_if_present(params, :avatar, :avatar)
197 |> add_if_present(params, :header, :banner)
198 |> add_if_present(params, :pleroma_background_image, :background)
199 |> add_if_present(
200 params,
201 :fields_attributes,
202 :raw_fields,
203 &{:ok, normalize_fields_attributes(&1)}
204 )
205 |> add_if_present(params, :pleroma_settings_store, :pleroma_settings_store)
206 |> add_if_present(params, :default_scope, :default_scope)
207 |> add_if_present(params, :actor_type, :actor_type)
208
209 changeset = User.update_changeset(user, user_params)
210
211 with {:ok, user} <- User.update_and_set_cache(changeset) do
212 if original_user != user, do: CommonAPI.update(user)
213
214 render(conn, "show.json", user: user, for: user, with_pleroma_settings: true)
215 else
216 _e -> render_error(conn, :forbidden, "Invalid request")
217 end
218 end
219
220 defp add_if_present(map, params, params_field, map_field, value_function \\ &{:ok, &1}) do
221 with true <- Map.has_key?(params, params_field),
222 {:ok, new_value} <- value_function.(Map.get(params, params_field)) do
223 Map.put(map, map_field, new_value)
224 else
225 _ -> map
226 end
227 end
228
229 defp normalize_fields_attributes(fields) do
230 if Enum.all?(fields, &is_tuple/1) do
231 Enum.map(fields, fn {_, v} -> v end)
232 else
233 Enum.map(fields, fn
234 %Pleroma.Web.ApiSpec.Schemas.AccountAttributeField{} = field ->
235 %{"name" => field.name, "value" => field.value}
236
237 field ->
238 field
239 end)
240 end
241 end
242
243 @doc "GET /api/v1/accounts/relationships"
244 def relationships(%{assigns: %{user: user}} = conn, %{id: id}) do
245 targets = User.get_all_by_ids(List.wrap(id))
246
247 render(conn, "relationships.json", user: user, targets: targets)
248 end
249
250 # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array.
251 def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, [])
252
253 @doc "GET /api/v1/accounts/:id"
254 def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id}) do
255 with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user),
256 true <- User.visible_for?(user, for_user) do
257 render(conn, "show.json", user: user, for: for_user)
258 else
259 _e -> render_error(conn, :not_found, "Can't find user")
260 end
261 end
262
263 @doc "GET /api/v1/accounts/:id/statuses"
264 def statuses(%{assigns: %{user: reading_user}} = conn, params) do
265 with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user),
266 true <- User.visible_for?(user, reading_user) do
267 params =
268 params
269 |> Map.delete(:tagged)
270 |> Enum.filter(&(not is_nil(&1)))
271 |> Map.new(fn {key, value} -> {to_string(key), value} end)
272 |> Map.put("tag", params[:tagged])
273
274 activities = ActivityPub.fetch_user_activities(user, reading_user, params)
275
276 conn
277 |> add_link_headers(activities)
278 |> put_view(StatusView)
279 |> render("index.json",
280 activities: activities,
281 for: reading_user,
282 as: :activity,
283 skip_relationships: skip_relationships?(params)
284 )
285 else
286 _e -> render_error(conn, :not_found, "Can't find user")
287 end
288 end
289
290 @doc "GET /api/v1/accounts/:id/followers"
291 def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do
292 params =
293 params
294 |> Enum.map(fn {key, value} -> {to_string(key), value} end)
295 |> Enum.into(%{})
296
297 followers =
298 cond do
299 for_user && user.id == for_user.id -> MastodonAPI.get_followers(user, params)
300 user.hide_followers -> []
301 true -> MastodonAPI.get_followers(user, params)
302 end
303
304 conn
305 |> add_link_headers(followers)
306 |> render("index.json", for: for_user, users: followers, as: :user)
307 end
308
309 @doc "GET /api/v1/accounts/:id/following"
310 def following(%{assigns: %{user: for_user, account: user}} = conn, params) do
311 params =
312 params
313 |> Enum.map(fn {key, value} -> {to_string(key), value} end)
314 |> Enum.into(%{})
315
316 followers =
317 cond do
318 for_user && user.id == for_user.id -> MastodonAPI.get_friends(user, params)
319 user.hide_follows -> []
320 true -> MastodonAPI.get_friends(user, params)
321 end
322
323 conn
324 |> add_link_headers(followers)
325 |> render("index.json", for: for_user, users: followers, as: :user)
326 end
327
328 @doc "GET /api/v1/accounts/:id/lists"
329 def lists(%{assigns: %{user: user, account: account}} = conn, _params) do
330 lists = Pleroma.List.get_lists_account_belongs(user, account)
331
332 conn
333 |> put_view(ListView)
334 |> render("index.json", lists: lists)
335 end
336
337 @doc "POST /api/v1/accounts/:id/follow"
338 def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
339 {:error, :not_found}
340 end
341
342 def follow(%{assigns: %{user: follower, account: followed}} = conn, params) do
343 with {:ok, follower} <- MastodonAPI.follow(follower, followed, params) do
344 render(conn, "relationship.json", user: follower, target: followed)
345 else
346 {:error, message} -> json_response(conn, :forbidden, %{error: message})
347 end
348 end
349
350 @doc "POST /api/v1/accounts/:id/unfollow"
351 def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
352 {:error, :not_found}
353 end
354
355 def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) do
356 with {:ok, follower} <- CommonAPI.unfollow(follower, followed) do
357 render(conn, "relationship.json", user: follower, target: followed)
358 end
359 end
360
361 @doc "POST /api/v1/accounts/:id/mute"
362 def mute(%{assigns: %{user: muter, account: muted}} = conn, params) do
363 notifications? = params |> Map.get("notifications", true) |> truthy_param?()
364
365 with {:ok, _user_relationships} <- User.mute(muter, muted, notifications?) do
366 render(conn, "relationship.json", user: muter, target: muted)
367 else
368 {:error, message} -> json_response(conn, :forbidden, %{error: message})
369 end
370 end
371
372 @doc "POST /api/v1/accounts/:id/unmute"
373 def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do
374 with {:ok, _user_relationships} <- User.unmute(muter, muted) do
375 render(conn, "relationship.json", user: muter, target: muted)
376 else
377 {:error, message} -> json_response(conn, :forbidden, %{error: message})
378 end
379 end
380
381 @doc "POST /api/v1/accounts/:id/block"
382 def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
383 with {:ok, _user_block} <- User.block(blocker, blocked),
384 {:ok, _activity} <- ActivityPub.block(blocker, blocked) do
385 render(conn, "relationship.json", user: blocker, target: blocked)
386 else
387 {:error, message} -> json_response(conn, :forbidden, %{error: message})
388 end
389 end
390
391 @doc "POST /api/v1/accounts/:id/unblock"
392 def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
393 with {:ok, _user_block} <- User.unblock(blocker, blocked),
394 {:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do
395 render(conn, "relationship.json", user: blocker, target: blocked)
396 else
397 {:error, message} -> json_response(conn, :forbidden, %{error: message})
398 end
399 end
400
401 @doc "POST /api/v1/follows"
402 def follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do
403 with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)},
404 {_, true} <- {:followed, follower.id != followed.id},
405 {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do
406 render(conn, "show.json", user: followed, for: follower)
407 else
408 {:followed, _} -> {:error, :not_found}
409 {:error, message} -> json_response(conn, :forbidden, %{error: message})
410 end
411 end
412
413 @doc "GET /api/v1/mutes"
414 def mutes(%{assigns: %{user: user}} = conn, _) do
415 users = User.muted_users(user, _restrict_deactivated = true)
416 render(conn, "index.json", users: users, for: user, as: :user)
417 end
418
419 @doc "GET /api/v1/blocks"
420 def blocks(%{assigns: %{user: user}} = conn, _) do
421 users = User.blocked_users(user, _restrict_deactivated = true)
422 render(conn, "index.json", users: users, for: user, as: :user)
423 end
424
425 @doc "GET /api/v1/endorsements"
426 def endorsements(conn, params),
427 do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params)
428 end