Merge remote-tracking branch 'remotes/origin/develop' into auth-improvements
[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 embed_relationships?: 1,
14 json_response: 3
15 ]
16
17 alias Pleroma.Maps
18 alias Pleroma.User
19 alias Pleroma.Web.ActivityPub.ActivityPub
20 alias Pleroma.Web.ActivityPub.Builder
21 alias Pleroma.Web.ActivityPub.Pipeline
22 alias Pleroma.Web.CommonAPI
23 alias Pleroma.Web.MastodonAPI.ListView
24 alias Pleroma.Web.MastodonAPI.MastodonAPI
25 alias Pleroma.Web.MastodonAPI.MastodonAPIController
26 alias Pleroma.Web.MastodonAPI.StatusView
27 alias Pleroma.Web.OAuth.OAuthController
28 alias Pleroma.Web.OAuth.OAuthView
29 alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug
30 alias Pleroma.Web.Plugs.OAuthScopesPlug
31 alias Pleroma.Web.Plugs.RateLimiter
32 alias Pleroma.Web.TwitterAPI.TwitterAPI
33
34 plug(Pleroma.Web.ApiSpec.CastAndValidate)
35
36 plug(:skip_plug, [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :create)
37
38 plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action in [:show, :statuses])
39
40 plug(
41 OAuthScopesPlug,
42 %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]}
43 when action in [:show, :followers, :following]
44 )
45
46 plug(
47 OAuthScopesPlug,
48 %{fallback: :proceed_unauthenticated, scopes: ["read:statuses"]}
49 when action == :statuses
50 )
51
52 plug(
53 OAuthScopesPlug,
54 %{scopes: ["read:accounts"]}
55 when action in [:verify_credentials, :endorsements, :identity_proofs]
56 )
57
58 plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :update_credentials)
59
60 plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :lists)
61
62 plug(
63 OAuthScopesPlug,
64 %{scopes: ["follow", "read:blocks"]} when action == :blocks
65 )
66
67 plug(
68 OAuthScopesPlug,
69 %{scopes: ["follow", "write:blocks"]} when action in [:block, :unblock]
70 )
71
72 plug(OAuthScopesPlug, %{scopes: ["read:follows"]} when action == :relationships)
73
74 plug(
75 OAuthScopesPlug,
76 %{scopes: ["follow", "write:follows"]} when action in [:follow_by_uri, :follow, :unfollow]
77 )
78
79 plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes)
80
81 plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute])
82
83 @relationship_actions [:follow, :unfollow]
84 @needs_account ~W(followers following lists follow unfollow mute unmute block unblock)a
85
86 plug(
87 RateLimiter,
88 [name: :relation_id_action, params: [:id, :uri]] when action in @relationship_actions
89 )
90
91 plug(RateLimiter, [name: :relations_actions] when action in @relationship_actions)
92 plug(RateLimiter, [name: :app_account_creation] when action == :create)
93 plug(:assign_account_by_id when action in @needs_account)
94
95 action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
96
97 defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AccountOperation
98
99 @doc "POST /api/v1/accounts"
100 def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do
101 with :ok <- validate_email_param(params),
102 :ok <- TwitterAPI.validate_captcha(app, params),
103 {:ok, user} <- TwitterAPI.register_user(params),
104 {_, {:ok, token}} <-
105 {:login, OAuthController.login(user, app, app.scopes)} do
106 json(conn, OAuthView.render("token.json", %{user: user, token: token}))
107 else
108 {:login, {:account_status, :confirmation_pending}} ->
109 json_response(conn, :ok, %{
110 message: "You have been registered. Please check your email for further instructions.",
111 identifier: "missing_confirmed_email"
112 })
113
114 {:login, {:account_status, :approval_pending}} ->
115 json_response(conn, :ok, %{
116 message:
117 "You have been registered. You'll be able to log in once your account is approved.",
118 identifier: "awaiting_approval"
119 })
120
121 {:login, _} ->
122 json_response(conn, :ok, %{
123 message:
124 "You have been registered. Some post-registration steps may be pending. " <>
125 "Please log in manually.",
126 identifier: "manual_login_required"
127 })
128
129 {:error, error} ->
130 json_response(conn, :bad_request, %{error: error})
131 end
132 end
133
134 def create(%{assigns: %{app: _app}} = conn, _) do
135 render_error(conn, :bad_request, "Missing parameters")
136 end
137
138 def create(conn, _) do
139 render_error(conn, :forbidden, "Invalid credentials")
140 end
141
142 defp validate_email_param(%{email: email}) when not is_nil(email), do: :ok
143
144 defp validate_email_param(_) do
145 case Pleroma.Config.get([:instance, :account_activation_required]) do
146 true -> {:error, dgettext("errors", "Missing parameter: %{name}", name: "email")}
147 _ -> :ok
148 end
149 end
150
151 @doc "GET /api/v1/accounts/verify_credentials"
152 def verify_credentials(%{assigns: %{user: user}} = conn, _) do
153 chat_token = Phoenix.Token.sign(conn, "user socket", user.id)
154
155 render(conn, "show.json",
156 user: user,
157 for: user,
158 with_pleroma_settings: true,
159 with_chat_token: chat_token
160 )
161 end
162
163 @doc "PATCH /api/v1/accounts/update_credentials"
164 def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _params) do
165 params =
166 params
167 |> Enum.filter(fn {_, value} -> not is_nil(value) end)
168 |> Enum.into(%{})
169
170 # We use an empty string as a special value to reset
171 # avatars, banners, backgrounds
172 user_image_value = fn
173 "" -> {:ok, nil}
174 value -> {:ok, value}
175 end
176
177 user_params =
178 [
179 :no_rich_text,
180 :hide_followers_count,
181 :hide_follows_count,
182 :hide_followers,
183 :hide_follows,
184 :hide_favorites,
185 :show_role,
186 :skip_thread_containment,
187 :allow_following_move,
188 :accepts_chat_messages
189 ]
190 |> Enum.reduce(%{}, fn key, acc ->
191 Maps.put_if_present(acc, key, params[key], &{:ok, truthy_param?(&1)})
192 end)
193 |> Maps.put_if_present(:name, params[:display_name])
194 |> Maps.put_if_present(:bio, params[:note])
195 |> Maps.put_if_present(:raw_bio, params[:note])
196 |> Maps.put_if_present(:avatar, params[:avatar], user_image_value)
197 |> Maps.put_if_present(:banner, params[:header], user_image_value)
198 |> Maps.put_if_present(:background, params[:pleroma_background_image], user_image_value)
199 |> Maps.put_if_present(
200 :raw_fields,
201 params[:fields_attributes],
202 &{:ok, normalize_fields_attributes(&1)}
203 )
204 |> Maps.put_if_present(:pleroma_settings_store, params[:pleroma_settings_store])
205 |> Maps.put_if_present(:default_scope, params[:default_scope])
206 |> Maps.put_if_present(:default_scope, params["source"]["privacy"])
207 |> Maps.put_if_present(:actor_type, params[:bot], fn bot ->
208 if bot, do: {:ok, "Service"}, else: {:ok, "Person"}
209 end)
210 |> Maps.put_if_present(:actor_type, params[:actor_type])
211 # Note: param name is indeed :locked (not an error)
212 |> Maps.put_if_present(:is_locked, params[:locked])
213 # Note: param name is indeed :discoverable (not an error)
214 |> Maps.put_if_present(:is_discoverable, params[:discoverable])
215
216 # What happens here:
217 #
218 # We want to update the user through the pipeline, but the ActivityPub
219 # update information is not quite enough for this, because this also
220 # contains local settings that don't federate and don't even appear
221 # in the Update activity.
222 #
223 # So we first build the normal local changeset, then apply it to the
224 # user data, but don't persist it. With this, we generate the object
225 # data for our update activity. We feed this and the changeset as meta
226 # inforation into the pipeline, where they will be properly updated and
227 # federated.
228 with changeset <- User.update_changeset(user, user_params),
229 {:ok, unpersisted_user} <- Ecto.Changeset.apply_action(changeset, :update),
230 updated_object <-
231 Pleroma.Web.ActivityPub.UserView.render("user.json", user: unpersisted_user)
232 |> Map.delete("@context"),
233 {:ok, update_data, []} <- Builder.update(user, updated_object),
234 {:ok, _update, _} <-
235 Pipeline.common_pipeline(update_data,
236 local: true,
237 user_update_changeset: changeset
238 ) do
239 render(conn, "show.json",
240 user: unpersisted_user,
241 for: unpersisted_user,
242 with_pleroma_settings: true
243 )
244 else
245 _e -> render_error(conn, :forbidden, "Invalid request")
246 end
247 end
248
249 defp normalize_fields_attributes(fields) do
250 if Enum.all?(fields, &is_tuple/1) do
251 Enum.map(fields, fn {_, v} -> v end)
252 else
253 Enum.map(fields, fn
254 %{} = field -> %{"name" => field.name, "value" => field.value}
255 field -> field
256 end)
257 end
258 end
259
260 @doc "GET /api/v1/accounts/relationships"
261 def relationships(%{assigns: %{user: user}} = conn, %{id: id}) do
262 targets = User.get_all_by_ids(List.wrap(id))
263
264 render(conn, "relationships.json", user: user, targets: targets)
265 end
266
267 # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array.
268 def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, [])
269
270 @doc "GET /api/v1/accounts/:id"
271 def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id}) do
272 with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user),
273 :visible <- User.visible_for(user, for_user) do
274 render(conn, "show.json", user: user, for: for_user)
275 else
276 error -> user_visibility_error(conn, error)
277 end
278 end
279
280 @doc "GET /api/v1/accounts/:id/statuses"
281 def statuses(%{assigns: %{user: reading_user}} = conn, params) do
282 with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user),
283 :visible <- User.visible_for(user, reading_user) do
284 params =
285 params
286 |> Map.delete(:tagged)
287 |> Map.put(:tag, params[:tagged])
288
289 activities = ActivityPub.fetch_user_activities(user, reading_user, params)
290
291 conn
292 |> add_link_headers(activities)
293 |> put_view(StatusView)
294 |> render("index.json",
295 activities: activities,
296 for: reading_user,
297 as: :activity,
298 with_muted: Map.get(params, :with_muted, false)
299 )
300 else
301 error -> user_visibility_error(conn, error)
302 end
303 end
304
305 defp user_visibility_error(conn, error) do
306 case error do
307 :restrict_unauthenticated ->
308 render_error(conn, :unauthorized, "This API requires an authenticated user")
309
310 _ ->
311 render_error(conn, :not_found, "Can't find user")
312 end
313 end
314
315 @doc "GET /api/v1/accounts/:id/followers"
316 def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do
317 params =
318 params
319 |> Enum.map(fn {key, value} -> {to_string(key), value} end)
320 |> Enum.into(%{})
321
322 followers =
323 cond do
324 for_user && user.id == for_user.id -> MastodonAPI.get_followers(user, params)
325 user.hide_followers -> []
326 true -> MastodonAPI.get_followers(user, params)
327 end
328
329 conn
330 |> add_link_headers(followers)
331 # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223
332 |> render("index.json",
333 for: for_user,
334 users: followers,
335 as: :user,
336 embed_relationships: embed_relationships?(params)
337 )
338 end
339
340 @doc "GET /api/v1/accounts/:id/following"
341 def following(%{assigns: %{user: for_user, account: user}} = conn, params) do
342 params =
343 params
344 |> Enum.map(fn {key, value} -> {to_string(key), value} end)
345 |> Enum.into(%{})
346
347 followers =
348 cond do
349 for_user && user.id == for_user.id -> MastodonAPI.get_friends(user, params)
350 user.hide_follows -> []
351 true -> MastodonAPI.get_friends(user, params)
352 end
353
354 conn
355 |> add_link_headers(followers)
356 # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223
357 |> render("index.json",
358 for: for_user,
359 users: followers,
360 as: :user,
361 embed_relationships: embed_relationships?(params)
362 )
363 end
364
365 @doc "GET /api/v1/accounts/:id/lists"
366 def lists(%{assigns: %{user: user, account: account}} = conn, _params) do
367 lists = Pleroma.List.get_lists_account_belongs(user, account)
368
369 conn
370 |> put_view(ListView)
371 |> render("index.json", lists: lists)
372 end
373
374 @doc "POST /api/v1/accounts/:id/follow"
375 def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
376 {:error, "Can not follow yourself"}
377 end
378
379 def follow(%{body_params: params, assigns: %{user: follower, account: followed}} = conn, _) do
380 with {:ok, follower} <- MastodonAPI.follow(follower, followed, params) do
381 render(conn, "relationship.json", user: follower, target: followed)
382 else
383 {:error, message} -> json_response(conn, :forbidden, %{error: message})
384 end
385 end
386
387 @doc "POST /api/v1/accounts/:id/unfollow"
388 def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
389 {:error, "Can not unfollow yourself"}
390 end
391
392 def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) do
393 with {:ok, follower} <- CommonAPI.unfollow(follower, followed) do
394 render(conn, "relationship.json", user: follower, target: followed)
395 end
396 end
397
398 @doc "POST /api/v1/accounts/:id/mute"
399 def mute(%{assigns: %{user: muter, account: muted}, body_params: params} = conn, _params) do
400 with {:ok, _user_relationships} <- User.mute(muter, muted, params) do
401 render(conn, "relationship.json", user: muter, target: muted)
402 else
403 {:error, message} -> json_response(conn, :forbidden, %{error: message})
404 end
405 end
406
407 @doc "POST /api/v1/accounts/:id/unmute"
408 def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do
409 with {:ok, _user_relationships} <- User.unmute(muter, muted) do
410 render(conn, "relationship.json", user: muter, target: muted)
411 else
412 {:error, message} -> json_response(conn, :forbidden, %{error: message})
413 end
414 end
415
416 @doc "POST /api/v1/accounts/:id/block"
417 def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
418 with {:ok, _activity} <- CommonAPI.block(blocker, blocked) do
419 render(conn, "relationship.json", user: blocker, target: blocked)
420 else
421 {:error, message} -> json_response(conn, :forbidden, %{error: message})
422 end
423 end
424
425 @doc "POST /api/v1/accounts/:id/unblock"
426 def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
427 with {:ok, _activity} <- CommonAPI.unblock(blocker, blocked) do
428 render(conn, "relationship.json", user: blocker, target: blocked)
429 else
430 {:error, message} -> json_response(conn, :forbidden, %{error: message})
431 end
432 end
433
434 @doc "POST /api/v1/follows"
435 def follow_by_uri(%{body_params: %{uri: uri}} = conn, _) do
436 case User.get_cached_by_nickname(uri) do
437 %User{} = user ->
438 conn
439 |> assign(:account, user)
440 |> follow(%{})
441
442 nil ->
443 {:error, :not_found}
444 end
445 end
446
447 @doc "GET /api/v1/mutes"
448 def mutes(%{assigns: %{user: user}} = conn, params) do
449 users =
450 user
451 |> User.muted_users_relation(_restrict_deactivated = true)
452 |> Pleroma.Pagination.fetch_paginated(Map.put(params, :skip_order, true))
453
454 conn
455 |> add_link_headers(users)
456 |> render("index.json", users: users, for: user, as: :user)
457 end
458
459 @doc "GET /api/v1/blocks"
460 def blocks(%{assigns: %{user: user}} = conn, params) do
461 users =
462 user
463 |> User.blocked_users_relation(_restrict_deactivated = true)
464 |> Pleroma.Pagination.fetch_paginated(Map.put(params, :skip_order, true))
465
466 conn
467 |> add_link_headers(users)
468 |> render("index.json", users: users, for: user, as: :user)
469 end
470
471 @doc "GET /api/v1/endorsements"
472 def endorsements(conn, params), do: MastodonAPIController.empty_array(conn, params)
473
474 @doc "GET /api/v1/identity_proofs"
475 def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params)
476 end