1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
5 defmodule Pleroma.Web.MastodonAPI.AccountController do
6 use Pleroma.Web, :controller
8 import Pleroma.Web.ControllerHelper,
11 assign_account_by_id: 2,
12 embed_relationships?: 1,
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
33 plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
35 plug(:skip_auth when action in [:create, :lookup])
37 plug(:skip_public_check when action in [:show, :statuses])
41 %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]}
42 when action in [:show, :followers, :following]
47 %{fallback: :proceed_unauthenticated, scopes: ["read:statuses"]}
48 when action == :statuses
53 %{scopes: ["read:accounts"]}
54 when action in [:verify_credentials, :endorsements, :identity_proofs]
59 %{scopes: ["write:accounts"]}
60 when action in [:update_credentials, :note, :endorse, :unendorse]
63 plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :lists)
67 %{scopes: ["follow", "read:blocks"]} when action == :blocks
72 %{scopes: ["follow", "write:blocks"]} when action in [:block, :unblock]
75 plug(OAuthScopesPlug, %{scopes: ["read:follows"]} when action == :relationships)
79 %{scopes: ["follow", "write:follows"]}
80 when action in [:follow_by_uri, :follow, :unfollow, :remove_from_followers]
83 plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes)
85 plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute])
87 @relationship_actions [:follow, :unfollow, :remove_from_followers]
89 followers following lists follow unfollow mute unmute block unblock
90 note endorse unendorse remove_from_followers
95 [name: :relation_id_action, params: ["id", "uri"]] when action in @relationship_actions
98 plug(RateLimiter, [name: :relations_actions] when action in @relationship_actions)
99 plug(RateLimiter, [name: :app_account_creation] when action == :create)
100 plug(:assign_account_by_id when action in @needs_account)
102 action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
104 defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AccountOperation
106 @doc "POST /api/v1/accounts"
108 %{assigns: %{app: app}, private: %{open_api_spex: %{body_params: params}}} = conn,
111 with :ok <- validate_email_param(params),
112 :ok <- TwitterAPI.validate_captcha(app, params),
113 {:ok, user} <- TwitterAPI.register_user(params),
115 {:login, OAuthController.login(user, app, app.scopes)} do
116 OAuthController.after_token_exchange(conn, %{user: user, token: token})
118 {:login, {:account_status, :confirmation_pending}} ->
119 json_response(conn, :ok, %{
120 message: "You have been registered. Please check your email for further instructions.",
121 identifier: "missing_confirmed_email"
124 {:login, {:account_status, :approval_pending}} ->
125 json_response(conn, :ok, %{
127 "You have been registered. You'll be able to log in once your account is approved.",
128 identifier: "awaiting_approval"
132 json_response(conn, :ok, %{
134 "You have been registered. Some post-registration steps may be pending. " <>
135 "Please log in manually.",
136 identifier: "manual_login_required"
140 json_response(conn, :bad_request, %{error: error})
144 def create(%{assigns: %{app: _app}} = conn, _) do
145 render_error(conn, :bad_request, "Missing parameters")
148 def create(conn, _) do
149 render_error(conn, :forbidden, "Invalid credentials")
152 defp validate_email_param(%{email: email}) when not is_nil(email), do: :ok
154 defp validate_email_param(_) do
155 case Pleroma.Config.get([:instance, :account_activation_required]) do
156 true -> {:error, dgettext("errors", "Missing parameter: %{name}", name: "email")}
161 @doc "GET /api/v1/accounts/verify_credentials"
162 def verify_credentials(%{assigns: %{user: user}} = conn, _) do
163 chat_token = Phoenix.Token.sign(conn, "user socket", user.id)
165 render(conn, "show.json",
168 with_pleroma_settings: true,
169 with_chat_token: chat_token
173 @doc "PATCH /api/v1/accounts/update_credentials"
174 def update_credentials(
175 %{assigns: %{user: user}, private: %{open_api_spex: %{body_params: params}}} = conn,
180 |> Enum.filter(fn {_, value} -> not is_nil(value) end)
183 # We use an empty string as a special value to reset
184 # avatars, banners, backgrounds
185 user_image_value = fn
187 value -> {:ok, value}
193 :hide_followers_count,
199 :skip_thread_containment,
200 :allow_following_move,
202 :accepts_chat_messages,
205 |> Enum.reduce(%{}, fn key, acc ->
206 Maps.put_if_present(acc, key, params[key], &{:ok, Params.truthy_param?(&1)})
208 |> Maps.put_if_present(:name, params[:display_name])
209 |> Maps.put_if_present(:bio, params[:note])
210 |> Maps.put_if_present(:raw_bio, params[:note])
211 |> Maps.put_if_present(:avatar, params[:avatar], user_image_value)
212 |> Maps.put_if_present(:banner, params[:header], user_image_value)
213 |> Maps.put_if_present(:background, params[:pleroma_background_image], user_image_value)
214 |> Maps.put_if_present(
216 params[:fields_attributes],
217 &{:ok, normalize_fields_attributes(&1)}
219 |> Maps.put_if_present(:pleroma_settings_store, params[:pleroma_settings_store])
220 |> Maps.put_if_present(:default_scope, params[:default_scope])
221 |> Maps.put_if_present(:default_scope, params["source"]["privacy"])
222 |> Maps.put_if_present(:actor_type, params[:bot], fn bot ->
223 if bot, do: {:ok, "Service"}, else: {:ok, "Person"}
225 |> Maps.put_if_present(:actor_type, params[:actor_type])
226 |> Maps.put_if_present(:also_known_as, params[:also_known_as])
227 # Note: param name is indeed :locked (not an error)
228 |> Maps.put_if_present(:is_locked, params[:locked])
229 # Note: param name is indeed :discoverable (not an error)
230 |> Maps.put_if_present(:is_discoverable, params[:discoverable])
231 |> Maps.put_if_present(:birthday, params[:birthday])
232 |> Maps.put_if_present(:language, Pleroma.Web.Gettext.normalize_locale(params[:language]))
236 # We want to update the user through the pipeline, but the ActivityPub
237 # update information is not quite enough for this, because this also
238 # contains local settings that don't federate and don't even appear
239 # in the Update activity.
241 # So we first build the normal local changeset, then apply it to the
242 # user data, but don't persist it. With this, we generate the object
243 # data for our update activity. We feed this and the changeset as meta
244 # information into the pipeline, where they will be properly updated and
246 with changeset <- User.update_changeset(user, user_params),
247 {:ok, unpersisted_user} <- Ecto.Changeset.apply_action(changeset, :update),
249 Pleroma.Web.ActivityPub.UserView.render("user.json", user: unpersisted_user)
250 |> Map.delete("@context"),
251 {:ok, update_data, []} <- Builder.update(user, updated_object),
253 Pipeline.common_pipeline(update_data,
255 user_update_changeset: changeset
257 render(conn, "show.json",
258 user: unpersisted_user,
259 for: unpersisted_user,
260 with_pleroma_settings: true
263 {:error, %Ecto.Changeset{errors: [avatar: {"file is too large", _}]}} ->
264 render_error(conn, :request_entity_too_large, "File is too large")
266 {:error, %Ecto.Changeset{errors: [banner: {"file is too large", _}]}} ->
267 render_error(conn, :request_entity_too_large, "File is too large")
269 {:error, %Ecto.Changeset{errors: [background: {"file is too large", _}]}} ->
270 render_error(conn, :request_entity_too_large, "File is too large")
272 {:error, %Ecto.Changeset{errors: [{:bio, {_, _}} | _]}} ->
273 render_error(conn, :request_entity_too_large, "Bio is too long")
275 {:error, %Ecto.Changeset{errors: [{:name, {_, _}} | _]}} ->
276 render_error(conn, :request_entity_too_large, "Name is too long")
278 {:error, %Ecto.Changeset{errors: [{:fields, {"invalid", _}} | _]}} ->
279 render_error(conn, :request_entity_too_large, "One or more field entries are too long")
281 {:error, %Ecto.Changeset{errors: [{:fields, {_, _}} | _]}} ->
282 render_error(conn, :request_entity_too_large, "Too many field entries")
285 render_error(conn, :forbidden, "Invalid request")
289 defp normalize_fields_attributes(fields) do
290 if(Enum.all?(fields, &is_tuple/1), do: Enum.map(fields, fn {_, v} -> v end), else: fields)
292 %{} = field -> %{"name" => field.name, "value" => field.value}
297 @doc "GET /api/v1/accounts/relationships"
299 %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn,
302 targets = User.get_all_by_ids(List.wrap(id))
304 render(conn, "relationships.json", user: user, targets: targets)
307 # Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array.
308 def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, [])
310 @doc "GET /api/v1/accounts/:id"
313 assigns: %{user: for_user},
314 private: %{open_api_spex: %{params: %{id: nickname_or_id} = params}}
318 with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user),
319 :visible <- User.visible_for(user, for_user) do
320 render(conn, "show.json",
323 embed_relationships: embed_relationships?(params)
326 error -> user_visibility_error(conn, error)
330 @doc "GET /api/v1/accounts/:id/statuses"
332 %{assigns: %{user: reading_user}, private: %{open_api_spex: %{params: params}}} = conn,
335 with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user),
336 :visible <- User.visible_for(user, reading_user) do
339 |> Map.delete(:tagged)
340 |> Map.put(:tag, params[:tagged])
342 activities = ActivityPub.fetch_user_activities(user, reading_user, params)
345 |> add_link_headers(activities)
346 |> put_view(StatusView)
347 |> render("index.json",
348 activities: activities,
351 with_muted: Map.get(params, :with_muted, false)
354 error -> user_visibility_error(conn, error)
358 defp user_visibility_error(conn, error) do
360 :restrict_unauthenticated ->
361 render_error(conn, :unauthorized, "This API requires an authenticated user")
364 render_error(conn, :not_found, "Can't find user")
368 @doc "GET /api/v1/accounts/:id/followers"
370 %{assigns: %{user: for_user, account: user}, private: %{open_api_spex: %{params: params}}} =
376 |> Enum.map(fn {key, value} -> {to_string(key), value} end)
381 for_user && user.id == for_user.id -> MastodonAPI.get_followers(user, params)
382 user.hide_followers -> []
383 true -> MastodonAPI.get_followers(user, params)
387 |> add_link_headers(followers)
388 # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223
389 |> render("index.json",
393 embed_relationships: embed_relationships?(params)
397 @doc "GET /api/v1/accounts/:id/following"
399 %{assigns: %{user: for_user, account: user}, private: %{open_api_spex: %{params: params}}} =
405 |> Enum.map(fn {key, value} -> {to_string(key), value} end)
410 for_user && user.id == for_user.id -> MastodonAPI.get_friends(user, params)
411 user.hide_follows -> []
412 true -> MastodonAPI.get_friends(user, params)
416 |> add_link_headers(followers)
417 # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223
418 |> render("index.json",
422 embed_relationships: embed_relationships?(params)
426 @doc "GET /api/v1/accounts/:id/lists"
427 def lists(%{assigns: %{user: user, account: account}} = conn, _params) do
428 lists = Pleroma.List.get_lists_account_belongs(user, account)
431 |> put_view(ListView)
432 |> render("index.json", lists: lists)
435 @doc "POST /api/v1/accounts/:id/follow"
436 def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
437 {:error, "Can not follow yourself"}
442 assigns: %{user: follower, account: followed},
443 private: %{open_api_spex: %{body_params: params}}
447 with {:ok, follower} <- MastodonAPI.follow(follower, followed, params) do
448 render(conn, "relationship.json", user: follower, target: followed)
450 {:error, message} -> json_response(conn, :forbidden, %{error: message})
454 @doc "POST /api/v1/accounts/:id/unfollow"
455 def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
456 {:error, "Can not unfollow yourself"}
459 def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) do
460 with {:ok, follower} <- CommonAPI.unfollow(follower, followed) do
461 render(conn, "relationship.json", user: follower, target: followed)
465 @doc "POST /api/v1/accounts/:id/mute"
468 assigns: %{user: muter, account: muted},
469 private: %{open_api_spex: %{body_params: params}}
475 |> Map.put_new(:duration, Map.get(params, :expires_in, 0))
477 with {:ok, _user_relationships} <- User.mute(muter, muted, params) do
478 render(conn, "relationship.json", user: muter, target: muted)
480 {:error, message} -> json_response(conn, :forbidden, %{error: message})
484 @doc "POST /api/v1/accounts/:id/unmute"
485 def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do
486 with {:ok, _user_relationships} <- User.unmute(muter, muted) do
487 render(conn, "relationship.json", user: muter, target: muted)
489 {:error, message} -> json_response(conn, :forbidden, %{error: message})
493 @doc "POST /api/v1/accounts/:id/block"
494 def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
495 with {:ok, _activity} <- CommonAPI.block(blocker, blocked) do
496 render(conn, "relationship.json", user: blocker, target: blocked)
498 {:error, message} -> json_response(conn, :forbidden, %{error: message})
502 @doc "POST /api/v1/accounts/:id/unblock"
503 def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
504 with {:ok, _activity} <- CommonAPI.unblock(blocker, blocked) do
505 render(conn, "relationship.json", user: blocker, target: blocked)
507 {:error, message} -> json_response(conn, :forbidden, %{error: message})
511 @doc "POST /api/v1/accounts/:id/note"
514 assigns: %{user: noter, account: target},
515 private: %{open_api_spex: %{body_params: %{comment: comment}}}
519 with {:ok, _user_note} <- UserNote.create(noter, target, comment) do
520 render(conn, "relationship.json", user: noter, target: target)
524 @doc "POST /api/v1/accounts/:id/pin"
525 def endorse(%{assigns: %{user: endorser, account: endorsed}} = conn, _params) do
526 with {:ok, _user_relationships} <- User.endorse(endorser, endorsed) do
527 render(conn, "relationship.json", user: endorser, target: endorsed)
529 {:error, message} -> json_response(conn, :bad_request, %{error: message})
533 @doc "POST /api/v1/accounts/:id/unpin"
534 def unendorse(%{assigns: %{user: endorser, account: endorsed}} = conn, _params) do
535 with {:ok, _user_relationships} <- User.unendorse(endorser, endorsed) do
536 render(conn, "relationship.json", user: endorser, target: endorsed)
538 {:error, message} -> json_response(conn, :forbidden, %{error: message})
542 @doc "POST /api/v1/accounts/:id/remove_from_followers"
543 def remove_from_followers(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
544 {:error, "Can not unfollow yourself"}
547 def remove_from_followers(%{assigns: %{user: followed, account: follower}} = conn, _params) do
548 with {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do
549 render(conn, "relationship.json", user: followed, target: follower)
552 render_error(conn, :not_found, "Record not found")
556 @doc "POST /api/v1/follows"
557 def follow_by_uri(%{private: %{open_api_spex: %{body_params: %{uri: uri}}}} = conn, _) do
558 case User.get_cached_by_nickname(uri) do
561 |> assign(:account, user)
569 @doc "GET /api/v1/mutes"
570 def mutes(%{assigns: %{user: user}} = conn, params) do
573 |> User.muted_users_relation(_restrict_deactivated = true)
574 |> Pleroma.Pagination.fetch_paginated(params)
577 |> add_link_headers(users)
578 |> render("index.json",
582 embed_relationships: embed_relationships?(params),
587 @doc "GET /api/v1/blocks"
588 def blocks(%{assigns: %{user: user}} = conn, params) do
591 |> User.blocked_users_relation(_restrict_deactivated = true)
592 |> Pleroma.Pagination.fetch_paginated(params)
595 |> add_link_headers(users)
596 |> render("index.json",
600 embed_relationships: embed_relationships?(params)
604 @doc "GET /api/v1/accounts/lookup"
605 def lookup(%{private: %{open_api_spex: %{params: %{acct: nickname}}}} = conn, _params) do
606 with %User{} = user <- User.get_by_nickname(nickname) do
607 render(conn, "show.json",
609 skip_visibility_check: true
612 error -> user_visibility_error(conn, error)
616 @doc "GET /api/v1/endorsements"
617 def endorsements(%{assigns: %{user: user}} = conn, params) do
620 |> User.endorsed_users_relation(_restrict_deactivated = true)
621 |> Pleroma.Repo.all()
624 |> render("index.json",
628 embed_relationships: embed_relationships?(params)
632 @doc "GET /api/v1/identity_proofs"
633 def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params)