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