Merge branch 'auth-improvements' into 'develop'
[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.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 :accepts_chat_messages
188 ]
189 |> Enum.reduce(%{}, fn key, acc ->
190 Maps.put_if_present(acc, key, params[key], &{:ok, truthy_param?(&1)})
191 end)
192 |> Maps.put_if_present(:name, params[:display_name])
193 |> Maps.put_if_present(:bio, params[:note])
194 |> Maps.put_if_present(:raw_bio, params[:note])
195 |> Maps.put_if_present(:avatar, params[:avatar], user_image_value)
196 |> Maps.put_if_present(:banner, params[:header], user_image_value)
197 |> Maps.put_if_present(:background, params[:pleroma_background_image], user_image_value)
198 |> Maps.put_if_present(
199 :raw_fields,
200 params[:fields_attributes],
201 &{:ok, normalize_fields_attributes(&1)}
202 )
203 |> Maps.put_if_present(:pleroma_settings_store, params[:pleroma_settings_store])
204 |> Maps.put_if_present(:default_scope, params[:default_scope])
205 |> Maps.put_if_present(:default_scope, params["source"]["privacy"])
206 |> Maps.put_if_present(:actor_type, params[:bot], fn bot ->
207 if bot, do: {:ok, "Service"}, else: {:ok, "Person"}
208 end)
209 |> Maps.put_if_present(:actor_type, params[:actor_type])
210 # Note: param name is indeed :locked (not an error)
211 |> Maps.put_if_present(:is_locked, params[:locked])
212 # Note: param name is indeed :discoverable (not an error)
213 |> Maps.put_if_present(:is_discoverable, params[:discoverable])
214
215 # What happens here:
216 #
217 # We want to update the user through the pipeline, but the ActivityPub
218 # update information is not quite enough for this, because this also
219 # contains local settings that don't federate and don't even appear
220 # in the Update activity.
221 #
222 # So we first build the normal local changeset, then apply it to the
223 # user data, but don't persist it. With this, we generate the object
224 # data for our update activity. We feed this and the changeset as meta
225 # inforation into the pipeline, where they will be properly updated and
226 # federated.
227 with changeset <- User.update_changeset(user, user_params),
228 {:ok, unpersisted_user} <- Ecto.Changeset.apply_action(changeset, :update),
229 updated_object <-
230 Pleroma.Web.ActivityPub.UserView.render("user.json", user: unpersisted_user)
231 |> Map.delete("@context"),
232 {:ok, update_data, []} <- Builder.update(user, updated_object),
233 {:ok, _update, _} <-
234 Pipeline.common_pipeline(update_data,
235 local: true,
236 user_update_changeset: changeset
237 ) do
238 render(conn, "show.json",
239 user: unpersisted_user,
240 for: unpersisted_user,
241 with_pleroma_settings: true
242 )
243 else
244 _e -> render_error(conn, :forbidden, "Invalid request")
245 end
246 end
247
248 defp normalize_fields_attributes(fields) do
249 if Enum.all?(fields, &is_tuple/1) do
250 Enum.map(fields, fn {_, v} -> v end)
251 else
252 Enum.map(fields, fn
253 %{} = field -> %{"name" => field.name, "value" => field.value}
254 field -> field
255 end)
256 end
257 end
258
259 @doc "GET /api/v1/accounts/relationships"
260 def relationships(%{assigns: %{user: user}} = conn, %{id: id}) do
261 targets = User.get_all_by_ids(List.wrap(id))
262
263 render(conn, "relationships.json", user: user, targets: targets)
264 end
265
266 # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array.
267 def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, [])
268
269 @doc "GET /api/v1/accounts/:id"
270 def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id}) do
271 with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user),
272 :visible <- User.visible_for(user, for_user) do
273 render(conn, "show.json", user: user, for: for_user)
274 else
275 error -> user_visibility_error(conn, error)
276 end
277 end
278
279 @doc "GET /api/v1/accounts/:id/statuses"
280 def statuses(%{assigns: %{user: reading_user}} = conn, params) do
281 with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user),
282 :visible <- User.visible_for(user, reading_user) do
283 params =
284 params
285 |> Map.delete(:tagged)
286 |> Map.put(:tag, params[:tagged])
287
288 activities = ActivityPub.fetch_user_activities(user, reading_user, params)
289
290 conn
291 |> add_link_headers(activities)
292 |> put_view(StatusView)
293 |> render("index.json",
294 activities: activities,
295 for: reading_user,
296 as: :activity,
297 with_muted: Map.get(params, :with_muted, false)
298 )
299 else
300 error -> user_visibility_error(conn, error)
301 end
302 end
303
304 defp user_visibility_error(conn, error) do
305 case error do
306 :restrict_unauthenticated ->
307 render_error(conn, :unauthorized, "This API requires an authenticated user")
308
309 _ ->
310 render_error(conn, :not_found, "Can't find user")
311 end
312 end
313
314 @doc "GET /api/v1/accounts/:id/followers"
315 def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do
316 params =
317 params
318 |> Enum.map(fn {key, value} -> {to_string(key), value} end)
319 |> Enum.into(%{})
320
321 followers =
322 cond do
323 for_user && user.id == for_user.id -> MastodonAPI.get_followers(user, params)
324 user.hide_followers -> []
325 true -> MastodonAPI.get_followers(user, params)
326 end
327
328 conn
329 |> add_link_headers(followers)
330 # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223
331 |> render("index.json",
332 for: for_user,
333 users: followers,
334 as: :user,
335 embed_relationships: embed_relationships?(params)
336 )
337 end
338
339 @doc "GET /api/v1/accounts/:id/following"
340 def following(%{assigns: %{user: for_user, account: user}} = conn, params) do
341 params =
342 params
343 |> Enum.map(fn {key, value} -> {to_string(key), value} end)
344 |> Enum.into(%{})
345
346 followers =
347 cond do
348 for_user && user.id == for_user.id -> MastodonAPI.get_friends(user, params)
349 user.hide_follows -> []
350 true -> MastodonAPI.get_friends(user, params)
351 end
352
353 conn
354 |> add_link_headers(followers)
355 # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223
356 |> render("index.json",
357 for: for_user,
358 users: followers,
359 as: :user,
360 embed_relationships: embed_relationships?(params)
361 )
362 end
363
364 @doc "GET /api/v1/accounts/:id/lists"
365 def lists(%{assigns: %{user: user, account: account}} = conn, _params) do
366 lists = Pleroma.List.get_lists_account_belongs(user, account)
367
368 conn
369 |> put_view(ListView)
370 |> render("index.json", lists: lists)
371 end
372
373 @doc "POST /api/v1/accounts/:id/follow"
374 def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
375 {:error, "Can not follow yourself"}
376 end
377
378 def follow(%{body_params: params, assigns: %{user: follower, account: followed}} = conn, _) do
379 with {:ok, follower} <- MastodonAPI.follow(follower, followed, params) do
380 render(conn, "relationship.json", user: follower, target: followed)
381 else
382 {:error, message} -> json_response(conn, :forbidden, %{error: message})
383 end
384 end
385
386 @doc "POST /api/v1/accounts/:id/unfollow"
387 def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
388 {:error, "Can not unfollow yourself"}
389 end
390
391 def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) do
392 with {:ok, follower} <- CommonAPI.unfollow(follower, followed) do
393 render(conn, "relationship.json", user: follower, target: followed)
394 end
395 end
396
397 @doc "POST /api/v1/accounts/:id/mute"
398 def mute(%{assigns: %{user: muter, account: muted}, body_params: params} = conn, _params) do
399 with {:ok, _user_relationships} <- User.mute(muter, muted, params) do
400 render(conn, "relationship.json", user: muter, target: muted)
401 else
402 {:error, message} -> json_response(conn, :forbidden, %{error: message})
403 end
404 end
405
406 @doc "POST /api/v1/accounts/:id/unmute"
407 def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do
408 with {:ok, _user_relationships} <- User.unmute(muter, muted) do
409 render(conn, "relationship.json", user: muter, target: muted)
410 else
411 {:error, message} -> json_response(conn, :forbidden, %{error: message})
412 end
413 end
414
415 @doc "POST /api/v1/accounts/:id/block"
416 def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
417 with {:ok, _activity} <- CommonAPI.block(blocker, blocked) do
418 render(conn, "relationship.json", user: blocker, target: blocked)
419 else
420 {:error, message} -> json_response(conn, :forbidden, %{error: message})
421 end
422 end
423
424 @doc "POST /api/v1/accounts/:id/unblock"
425 def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
426 with {:ok, _activity} <- CommonAPI.unblock(blocker, blocked) do
427 render(conn, "relationship.json", user: blocker, target: blocked)
428 else
429 {:error, message} -> json_response(conn, :forbidden, %{error: message})
430 end
431 end
432
433 @doc "POST /api/v1/follows"
434 def follow_by_uri(%{body_params: %{uri: uri}} = conn, _) do
435 case User.get_cached_by_nickname(uri) do
436 %User{} = user ->
437 conn
438 |> assign(:account, user)
439 |> follow(%{})
440
441 nil ->
442 {:error, :not_found}
443 end
444 end
445
446 @doc "GET /api/v1/mutes"
447 def mutes(%{assigns: %{user: user}} = conn, params) do
448 users =
449 user
450 |> User.muted_users_relation(_restrict_deactivated = true)
451 |> Pleroma.Pagination.fetch_paginated(Map.put(params, :skip_order, true))
452
453 conn
454 |> add_link_headers(users)
455 |> render("index.json", users: users, for: user, as: :user)
456 end
457
458 @doc "GET /api/v1/blocks"
459 def blocks(%{assigns: %{user: user}} = conn, params) do
460 users =
461 user
462 |> User.blocked_users_relation(_restrict_deactivated = true)
463 |> Pleroma.Pagination.fetch_paginated(Map.put(params, :skip_order, true))
464
465 conn
466 |> add_link_headers(users)
467 |> render("index.json", users: users, for: user, as: :user)
468 end
469
470 @doc "GET /api/v1/endorsements"
471 def endorsements(conn, params), do: MastodonAPIController.empty_array(conn, params)
472
473 @doc "GET /api/v1/identity_proofs"
474 def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params)
475 end