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